From 17d082f7f72fb9cb2f29866780e1498c83beaf67 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:20:59 -0500 Subject: [PATCH 01/12] feat: add paginated admin users query --- lib/lightning/accounts.ex | 126 +++++++++++++++++++++++++++++++ test/lightning/accounts_test.exs | 51 +++++++++++++ 2 files changed, 177 insertions(+) diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex index c4a45adf1ea..75a18ccaa8a 100644 --- a/lib/lightning/accounts.ex +++ b/lib/lightning/accounts.ex @@ -114,6 +114,31 @@ defmodule Lightning.Accounts do Repo.all(User) end + @admin_user_default_sort "email" + @admin_user_allowed_sorts ~w(first_name last_name email role enabled support_user scheduled_deletion) + @admin_user_default_page_size 10 + @admin_user_max_page_size 100 + + @doc """ + Returns a paginated list of users for the superuser admin table with + server-side filtering and sorting. + """ + @spec list_users_for_admin(map()) :: Scrivener.Page.t() + def list_users_for_admin(params \\ %{}) do + %{ + filter: filter, + sort: sort, + dir: dir, + page: page, + page_size: page_size + } = normalize_admin_user_params(params) + + User + |> filter_admin_users(filter) + |> order_admin_users(sort, dir) + |> Repo.paginate(page: page, page_size: page_size) + end + @doc """ Returns the list of users with the given emails """ @@ -160,6 +185,107 @@ defmodule Lightning.Accounts do if User.valid_password?(user, password), do: user end + defp normalize_admin_user_params(params) do + params = stringify_param_keys(params) + + sort = normalize_admin_sort(Map.get(params, "sort")) + dir = normalize_admin_dir(Map.get(params, "dir")) + page = parse_positive_int(Map.get(params, "page"), 1) + + page_size = + Map.get(params, "page_size") + |> parse_positive_int(@admin_user_default_page_size) + |> min(@admin_user_max_page_size) + + %{ + filter: normalize_admin_filter(Map.get(params, "filter")), + sort: sort, + dir: dir, + page: page, + page_size: page_size + } + end + + defp filter_admin_users(query, ""), do: query + + defp filter_admin_users(query, filter) do + search = "%#{filter}%" + + where( + query, + [u], + ilike(u.first_name, ^search) or + ilike(u.last_name, ^search) or + ilike(u.email, ^search) or + ilike(fragment("?::text", u.role), ^search) + ) + end + + defp order_admin_users(query, "enabled", "asc"), + do: order_by(query, [u], [desc: u.disabled]) + + defp order_admin_users(query, "enabled", "desc"), + do: order_by(query, [u], [asc: u.disabled]) + + defp order_admin_users(query, "scheduled_deletion", "asc"), + do: order_by(query, [u], [asc_nulls_last: u.scheduled_deletion]) + + defp order_admin_users(query, "scheduled_deletion", "desc"), + do: order_by(query, [u], [desc_nulls_first: u.scheduled_deletion]) + + defp order_admin_users(query, "role", dir) do + direction = dir_to_atom(dir) + order_by(query, [u], [{^direction, fragment("?::text", u.role)}]) + end + + defp order_admin_users(query, sort, dir) do + direction = dir_to_atom(dir) + sort_field = String.to_existing_atom(sort) + + order_by(query, [u], [{^direction, field(u, ^sort_field)}]) + end + + defp normalize_admin_sort(sort) when is_binary(sort) do + if sort in @admin_user_allowed_sorts, do: sort, else: @admin_user_default_sort + end + + defp normalize_admin_sort(sort) when is_atom(sort) do + sort + |> Atom.to_string() + |> normalize_admin_sort() + end + + defp normalize_admin_sort(_), do: @admin_user_default_sort + + defp normalize_admin_dir(dir) when dir in ["asc", :asc], do: "asc" + defp normalize_admin_dir(dir) when dir in ["desc", :desc], do: "desc" + defp normalize_admin_dir(_), do: "asc" + + defp normalize_admin_filter(nil), do: "" + + defp normalize_admin_filter(filter) do + filter + |> to_string() + |> String.trim() + end + + defp stringify_param_keys(params) when is_map(params) do + Map.new(params, fn {key, value} -> {to_string(key), value} end) + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, + do: value + + defp parse_positive_int(value, default) do + case Integer.parse(to_string(value || "")) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp dir_to_atom("asc"), do: :asc + defp dir_to_atom("desc"), do: :desc + @doc """ Gets a single user. diff --git a/test/lightning/accounts_test.exs b/test/lightning/accounts_test.exs index c515d3776bf..c48ccb60f05 100644 --- a/test/lightning/accounts_test.exs +++ b/test/lightning/accounts_test.exs @@ -98,6 +98,57 @@ defmodule Lightning.AccountsTest do assert [%{id: ^user_id}] = Accounts.list_users() end + describe "list_users_for_admin/1" do + test "returns a paginated, searchable, sortable users page" do + insert(:user, + first_name: "Alice", + last_name: "Able", + email: "alice.admin@example.com" + ) + + insert(:user, + first_name: "Bob", + last_name: "Baker", + email: "bob.admin@example.com" + ) + + insert(:user, + first_name: "Zoe", + last_name: "Zulu", + email: "zoe.admin@example.com" + ) + + page = + Accounts.list_users_for_admin(%{ + "filter" => "bo", + "sort" => "email", + "dir" => "asc", + "page" => "1", + "page_size" => "10" + }) + + assert page.page_number == 1 + assert page.page_size == 10 + assert Enum.map(page.entries, & &1.email) == ["bob.admin@example.com"] + end + + test "falls back to safe defaults for invalid params" do + insert(:user, email: "fallback.admin@example.com") + + page = + Accounts.list_users_for_admin(%{ + "sort" => "not_a_column", + "dir" => "boom", + "page" => "-10", + "page_size" => "1000" + }) + + assert page.page_number == 1 + assert page.page_size <= 100 + assert is_list(page.entries) + end + end + test "list_api_token/1 returns all user tokens" do user = insert(:user) From 0679a9d12277b65c0fef2a5766b9375a875c6a9e Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:22:51 -0500 Subject: [PATCH 02/12] feat: add paginated admin projects query --- lib/lightning/projects.ex | 129 +++++++++++++++++++++++++++++++ test/lightning/projects_test.exs | 25 ++++++ 2 files changed, 154 insertions(+) diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index c806608e192..2565d19b34b 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -191,6 +191,34 @@ defmodule Lightning.Projects do Repo.all(from(p in Project, order_by: p.name)) end + @admin_project_default_sort "name" + @admin_project_allowed_sorts ~w(name inserted_at description owner scheduled_deletion) + @admin_project_default_page_size 10 + @admin_project_max_page_size 100 + + @doc """ + Returns a paginated list of projects for the superuser admin table with + server-side filtering and sorting. + """ + @spec list_projects_for_admin(map()) :: Scrivener.Page.t() + def list_projects_for_admin(params \\ %{}) do + %{ + filter: filter, + sort: sort, + dir: dir, + page: page, + page_size: page_size + } = normalize_admin_project_params(params) + + Project + |> join(:left, [p], pu in assoc(p, :project_users), on: pu.role == :owner) + |> join(:left, [_p, pu], owner in assoc(pu, :user)) + |> preload([_p, _pu, _owner], project_users: :user) + |> filter_admin_projects(filter) + |> order_admin_projects(sort, dir) + |> Repo.paginate(page: page, page_size: page_size) + end + @doc """ Lists all projects that have history retention """ @@ -220,6 +248,107 @@ defmodule Lightning.Projects do end end + defp normalize_admin_project_params(params) do + params = stringify_param_keys(params) + + sort = normalize_admin_project_sort(Map.get(params, "sort")) + dir = normalize_admin_project_dir(Map.get(params, "dir")) + page = parse_positive_int(Map.get(params, "page"), 1) + + page_size = + Map.get(params, "page_size") + |> parse_positive_int(@admin_project_default_page_size) + |> min(@admin_project_max_page_size) + + %{ + filter: normalize_admin_project_filter(Map.get(params, "filter")), + sort: sort, + dir: dir, + page: page, + page_size: page_size + } + end + + defp filter_admin_projects(query, ""), do: query + + defp filter_admin_projects(query, filter) do + search = "%#{filter}%" + + where( + query, + [p, _pu, owner], + ilike(p.name, ^search) or + ilike(p.description, ^search) or + ilike(fragment("concat_ws(' ', ?, ?)", owner.first_name, owner.last_name), ^search) + ) + end + + defp order_admin_projects(query, "scheduled_deletion", "asc"), + do: order_by(query, [p, _pu, _owner], [asc_nulls_last: p.scheduled_deletion]) + + defp order_admin_projects(query, "scheduled_deletion", "desc"), + do: order_by(query, [p, _pu, _owner], [desc_nulls_first: p.scheduled_deletion]) + + defp order_admin_projects(query, "owner", dir) do + direction = project_dir_to_atom(dir) + + order_by( + query, + [_p, _pu, owner], + [{^direction, fragment("concat_ws(' ', ?, ?)", owner.first_name, owner.last_name)}] + ) + end + + defp order_admin_projects(query, sort, dir) do + direction = project_dir_to_atom(dir) + sort_field = String.to_existing_atom(sort) + + order_by(query, [p, _pu, _owner], [{^direction, field(p, ^sort_field)}]) + end + + defp normalize_admin_project_sort(sort) when is_binary(sort) do + if sort in @admin_project_allowed_sorts, + do: sort, + else: @admin_project_default_sort + end + + defp normalize_admin_project_sort(sort) when is_atom(sort) do + sort + |> Atom.to_string() + |> normalize_admin_project_sort() + end + + defp normalize_admin_project_sort(_), do: @admin_project_default_sort + + defp normalize_admin_project_dir(dir) when dir in ["asc", :asc], do: "asc" + defp normalize_admin_project_dir(dir) when dir in ["desc", :desc], do: "desc" + defp normalize_admin_project_dir(_), do: "asc" + + defp normalize_admin_project_filter(nil), do: "" + + defp normalize_admin_project_filter(filter) do + filter + |> to_string() + |> String.trim() + end + + defp stringify_param_keys(params) when is_map(params) do + Map.new(params, fn {key, value} -> {to_string(key), value} end) + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, + do: value + + defp parse_positive_int(value, default) do + case Integer.parse(to_string(value || "")) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp project_dir_to_atom("asc"), do: :asc + defp project_dir_to_atom("desc"), do: :desc + @doc """ Gets the project associated with a run. Traverses Run → WorkOrder → Workflow → Project. diff --git a/test/lightning/projects_test.exs b/test/lightning/projects_test.exs index deb038a5d65..6518e6aab4b 100644 --- a/test/lightning/projects_test.exs +++ b/test/lightning/projects_test.exs @@ -33,6 +33,31 @@ defmodule Lightning.ProjectsTest do assert Projects.list_projects() == [project] end + test "list_projects_for_admin/1 supports search by project fields and owner name" do + owner = insert(:user, first_name: "Jane", last_name: "Owner") + + project = + insert(:project, name: "alpha-project", description: "first project") + + insert(:project_user, project: project, user: owner, role: :owner) + + _other = + insert(:project, name: "beta-project", description: "second project") + + page = + Projects.list_projects_for_admin(%{ + "filter" => "jane", + "sort" => "owner", + "dir" => "asc", + "page" => "1", + "page_size" => "10" + }) + + assert page.page_number == 1 + assert page.page_size == 10 + assert Enum.map(page.entries, & &1.id) == [project.id] + end + test "list_project_credentials/1 returns all project_credentials for a project" do user = insert(:user) project = insert(:project, project_users: [%{user_id: user.id}]) From 4c85c3860c71602b50dc2e08a2c8ffd64ce4c715 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:32:06 -0500 Subject: [PATCH 03/12] perf: add trigram indexes for admin users/projects search --- ...26162000_add_admin_search_trgm_indexes.exs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs diff --git a/priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs b/priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs new file mode 100644 index 00000000000..eab307980ab --- /dev/null +++ b/priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs @@ -0,0 +1,28 @@ +defmodule Lightning.Repo.Migrations.AddAdminSearchTrgmIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute "CREATE EXTENSION IF NOT EXISTS pg_trgm" + + execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS users_first_name_trgm_idx ON users USING GIN (first_name gin_trgm_ops) WHERE first_name IS NOT NULL" + + execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS users_last_name_trgm_idx ON users USING GIN (last_name gin_trgm_ops) WHERE last_name IS NOT NULL" + + execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS users_email_trgm_idx ON users USING GIN ((email::text) gin_trgm_ops) WHERE email IS NOT NULL" + + execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS projects_name_trgm_idx ON projects USING GIN (name gin_trgm_ops) WHERE name IS NOT NULL" + + execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS projects_description_trgm_idx ON projects USING GIN (description gin_trgm_ops) WHERE description IS NOT NULL" + end + + def down do + execute "DROP INDEX CONCURRENTLY IF EXISTS users_first_name_trgm_idx" + execute "DROP INDEX CONCURRENTLY IF EXISTS users_last_name_trgm_idx" + execute "DROP INDEX CONCURRENTLY IF EXISTS users_email_trgm_idx" + execute "DROP INDEX CONCURRENTLY IF EXISTS projects_name_trgm_idx" + execute "DROP INDEX CONCURRENTLY IF EXISTS projects_description_trgm_idx" + end +end From fb9006de30c526f4ae4a8be72d1fde2eb56cfa13 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:34:31 -0500 Subject: [PATCH 04/12] feat: server-side pagination and search for admin users list --- .../live/user_live/components.ex | 7 +- lib/lightning_web/live/user_live/index.ex | 62 +++++++++- .../live/user_live/index.html.heex | 1 + .../live/user_live/table_component.ex | 110 +++++++++--------- test/lightning_web/live/user_live_test.exs | 26 +++++ 5 files changed, 149 insertions(+), 57 deletions(-) diff --git a/lib/lightning_web/live/user_live/components.ex b/lib/lightning_web/live/user_live/components.ex index 3ad2b637a49..2aa7023f105 100644 --- a/lib/lightning_web/live/user_live/components.ex +++ b/lib/lightning_web/live/user_live/components.ex @@ -2,7 +2,8 @@ defmodule LightningWeb.UserLive.Components do use LightningWeb, :component attr :socket, :map, required: true - attr :users, :list, required: true + attr :page, :map, required: true + attr :pagination_path, :any, required: true attr :live_action, :atom, required: true attr :sort_key, :string, default: "email" attr :sort_direction, :string, default: "asc" @@ -32,7 +33,7 @@ defmodule LightningWeb.UserLive.Components do target={@target} /> - <.table> + <.table page={@page} url={@pagination_path}> <:header> <.tr> <.th @@ -102,7 +103,7 @@ defmodule LightningWeb.UserLive.Components do <:body> - <%= for user <- @users do %> + <%= for user <- @page.entries do %> <.tr id={"user-#{user.id}"}> <.td class="max-w-40 wrap-break-word" title={user.first_name}> {user.first_name} diff --git a/lib/lightning_web/live/user_live/index.ex b/lib/lightning_web/live/user_live/index.ex index 9302cfb7cf2..6f70a081bcf 100644 --- a/lib/lightning_web/live/user_live/index.ex +++ b/lib/lightning_web/live/user_live/index.ex @@ -8,6 +8,11 @@ defmodule LightningWeb.UserLive.Index do alias Lightning.Policies.Permissions alias Lightning.Policies.Users + @default_sort "email" + @allowed_sorts ~w(first_name last_name email role enabled support_user scheduled_deletion) + @default_page_size 10 + @max_page_size 100 + @impl true def mount(_params, _session, socket) do can_access_admin_space = @@ -30,7 +35,12 @@ defmodule LightningWeb.UserLive.Index do @impl true def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} + socket = + socket + |> assign(:table_params, normalize_table_params(params)) + |> apply_action(socket.assigns.live_action, params) + + {:noreply, socket} end defp apply_action(socket, :index, _params) do @@ -64,4 +74,54 @@ defmodule LightningWeb.UserLive.Index do |> put_flash(:error, "Cancel user deletion failed")} end end + + defp normalize_table_params(params) do + params = Map.new(params, fn {k, v} -> {to_string(k), v} end) + + %{ + "filter" => normalize_filter(Map.get(params, "filter")), + "sort" => normalize_sort(Map.get(params, "sort")), + "dir" => normalize_dir(Map.get(params, "dir")), + "page" => Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), + "page_size" => + Map.get(params, "page_size") + |> parse_positive_int(@default_page_size) + |> min(@max_page_size) + |> Integer.to_string() + } + end + + defp normalize_sort(sort) when is_binary(sort) do + if sort in @allowed_sorts, do: sort, else: @default_sort + end + + defp normalize_sort(sort) when is_atom(sort) do + sort + |> Atom.to_string() + |> normalize_sort() + end + + defp normalize_sort(_), do: @default_sort + + defp normalize_dir(dir) when dir in ["asc", :asc], do: "asc" + defp normalize_dir(dir) when dir in ["desc", :desc], do: "desc" + defp normalize_dir(_), do: "asc" + + defp normalize_filter(nil), do: "" + + defp normalize_filter(filter) do + filter + |> to_string() + |> String.trim() + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, + do: value + + defp parse_positive_int(value, default) do + case Integer.parse(to_string(value || "")) do + {int, ""} when int > 0 -> int + _ -> default + end + end end diff --git a/lib/lightning_web/live/user_live/index.html.heex b/lib/lightning_web/live/user_live/index.html.heex index 57f67702eb7..ae6fb0fa6e2 100644 --- a/lib/lightning_web/live/user_live/index.html.heex +++ b/lib/lightning_web/live/user_live/index.html.heex @@ -18,6 +18,7 @@ module={LightningWeb.UserLive.TableComponent} delete_user={assigns[:delete_user]} live_action={@live_action} + table_params={@table_params} user_deletion_modal={LightningWeb.Components.UserDeletionModal} /> diff --git a/lib/lightning_web/live/user_live/table_component.ex b/lib/lightning_web/live/user_live/table_component.ex index 9a628093e2f..d5902efdc8a 100644 --- a/lib/lightning_web/live/user_live/table_component.ex +++ b/lib/lightning_web/live/user_live/table_component.ex @@ -7,6 +7,14 @@ defmodule LightningWeb.UserLive.TableComponent do alias Lightning.Accounts alias LightningWeb.Live.Helpers.TableHelpers + @default_table_params %{ + "filter" => "", + "sort" => "email", + "dir" => "asc", + "page" => "1", + "page_size" => "10" + } + @impl true def render(assigns) do ~H""" @@ -16,7 +24,8 @@ defmodule LightningWeb.UserLive.TableComponent do target={@myself} live_action={@live_action} delete_user={assigns[:delete_user]} - users={@users} + page={@page} + pagination_path={@pagination_path} sort_key={@sort_key} sort_direction={@sort_direction} filter={@filter} @@ -28,18 +37,23 @@ defmodule LightningWeb.UserLive.TableComponent do @impl true def mount(socket) do + page = Accounts.list_users_for_admin(@default_table_params) + {:ok, - assign(socket, - users: list_users("", "email", "asc"), - sort_key: "email", - sort_direction: "asc", - filter: "" - )} + socket + |> assign(:table_params, @default_table_params) + |> assign_table_state(@default_table_params, page)} end @impl true def update(assigns, socket) do - {:ok, assign(socket, assigns)} + table_params = Map.get(assigns, :table_params, socket.assigns.table_params) + page = Accounts.list_users_for_admin(table_params) + + {:ok, + socket + |> assign(assigns) + |> assign_table_state(table_params, page)} end @impl true @@ -51,67 +65,57 @@ defmodule LightningWeb.UserLive.TableComponent do sort_key ) - users = list_users(socket.assigns.filter, sort_key, sort_direction) + params = + socket.assigns.table_params + |> Map.put("sort", sort_key) + |> Map.put("dir", sort_direction) + |> Map.put("page", "1") {:noreply, - assign(socket, - users: users, - sort_key: sort_key, - sort_direction: sort_direction - )} + push_patch(socket, to: Routes.user_index_path(socket, :index, params))} end def handle_event("filter", %{"value" => filter}, socket) do - users = - list_users(filter, socket.assigns.sort_key, socket.assigns.sort_direction) + params = + socket.assigns.table_params + |> Map.put("filter", String.trim(filter)) + |> Map.put("page", "1") {:noreply, - assign(socket, - users: users, - filter: filter - )} + push_patch(socket, to: Routes.user_index_path(socket, :index, params))} end def handle_event("clear_filter", _params, socket) do - users = - list_users("", socket.assigns.sort_key, socket.assigns.sort_direction) + params = + socket.assigns.table_params + |> Map.put("filter", "") + |> Map.put("page", "1") {:noreply, - assign(socket, - users: users, - filter: "" - )} + push_patch(socket, to: Routes.user_index_path(socket, :index, params))} end - defp list_users(filter, sort_key, sort_direction) do - users = Accounts.list_users() - - TableHelpers.filter_and_sort( - users, - filter, - user_search_fields(), - sort_key, - sort_direction, - user_sort_map() + defp assign_table_state(socket, table_params, page) do + assign(socket, + page: page, + filter: table_params["filter"], + sort_key: table_params["sort"], + sort_direction: table_params["dir"], + table_params: table_params, + pagination_path: pagination_path(socket, table_params) ) end - # Configuration for user table sorting - defp user_sort_map do - %{ - "first_name" => :first_name, - "last_name" => :last_name, - "email" => :email, - "role" => fn user -> to_string(user.role) end, - "enabled" => fn user -> !user.disabled end, - "support_user" => :support_user, - "scheduled_deletion" => fn user -> - user.scheduled_deletion || ~U[9999-12-31 23:59:59Z] - end - } - end - - defp user_search_fields do - [:first_name, :last_name, :email, fn user -> to_string(user.role) end] + defp pagination_path(socket, table_params) do + fn route_params -> + params = + route_params + |> Enum.into(%{}) + |> Map.merge(Map.take(table_params, ["filter", "sort", "dir", "page_size"])) + |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) + |> Map.new() + + Routes.user_index_path(socket, :index, params) + end end end diff --git a/test/lightning_web/live/user_live_test.exs b/test/lightning_web/live/user_live_test.exs index 35899458e0d..93bdd488c8e 100644 --- a/test/lightning_web/live/user_live_test.exs +++ b/test/lightning_web/live/user_live_test.exs @@ -511,6 +511,32 @@ defmodule LightningWeb.UserLiveTest do ]) end + test "users index paginates and navigates pages", %{conn: conn} do + for i <- 1..25 do + user_fixture( + email: "page-user-#{i}@example.com", + first_name: "Page#{i}", + last_name: "User" + ) + end + + {:ok, index_live, html} = live(conn, Routes.user_index_path(conn, :index)) + + assert html =~ "Showing" + assert has_element?(index_live, "nav[aria-label='Pagination'] a", "2") + + index_live + |> element("nav[aria-label='Pagination'] a", "2") + |> render_click() + + patched_path = assert_patch(index_live) + patch_uri = URI.parse(patched_path) + patch_params = URI.decode_query(patch_uri.query || "") + + assert patch_uri.path == "/settings/users" + assert patch_params["page"] == "2" + end + test "sorting by first name column works correctly", %{conn: conn} do _user_a = user_fixture(first_name: "Alice", email: "alice@example.com") _user_b = user_fixture(first_name: "Bob", email: "bob@example.com") From 46817031738b479044ab1fd1e1956218c6b29839 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:37:09 -0500 Subject: [PATCH 05/12] feat: server-side pagination and search for admin projects list --- lib/lightning_web/live/project_live/index.ex | 179 +++++++++++------- .../live/project_live/index.html.heex | 4 +- test/lightning_web/live/project_live_test.exs | 23 +++ 3 files changed, 132 insertions(+), 74 deletions(-) diff --git a/lib/lightning_web/live/project_live/index.ex b/lib/lightning_web/live/project_live/index.ex index 3339ff6b903..99c450cec9f 100644 --- a/lib/lightning_web/live/project_live/index.ex +++ b/lib/lightning_web/live/project_live/index.ex @@ -4,32 +4,16 @@ defmodule LightningWeb.ProjectLive.Index do """ use LightningWeb, :live_view - import Ecto.Query + alias Lightning.Accounts alias Lightning.Policies.Permissions alias Lightning.Policies.Users alias Lightning.Projects alias LightningWeb.Live.Helpers.TableHelpers - # Configuration for project table sorting - defp project_sort_map do - %{ - "name" => fn project -> project.name || "" end, - "inserted_at" => :inserted_at, - "description" => fn project -> project.description || "" end, - "owner" => fn project -> get_project_owner_name(project) end, - "scheduled_deletion" => fn project -> - project.scheduled_deletion || ~U[9999-12-31 23:59:59Z] - end - } - end - - defp project_search_fields do - [ - fn project -> project.name || "" end, - fn project -> project.description || "" end, - fn project -> get_project_owner_name(project) end - ] - end + @default_sort "name" + @allowed_sorts ~w(name inserted_at description owner scheduled_deletion) + @default_page_size 10 + @max_page_size 100 @impl true def mount(_params, _session, socket) do @@ -48,18 +32,27 @@ defmodule LightningWeb.ProjectLive.Index do @impl true def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} + socket = + socket + |> assign(:table_params, normalize_table_params(params)) + |> apply_action(socket.assigns.live_action, params) + + {:noreply, socket} end defp apply_action(socket, :index, _params) do + table_params = socket.assigns.table_params + page = Projects.list_projects_for_admin(table_params) + socket |> assign( page_title: "Projects", active_menu_item: :projects, - projects: list_projects("", "name", "asc"), - sort_key: "name", - sort_direction: "asc", - filter: "" + page: page, + pagination_path: pagination_path(socket, table_params), + sort_key: table_params["sort"], + sort_direction: table_params["dir"], + filter: table_params["filter"] ) end @@ -69,7 +62,7 @@ defmodule LightningWeb.ProjectLive.Index do page_title: "Edit Project", active_menu_item: :projects, project: Projects.get_project_with_users!(id), - users: Lightning.Accounts.list_users(), + users: Accounts.list_users(), sort_key: "name", sort_direction: "asc", filter: "" @@ -82,7 +75,7 @@ defmodule LightningWeb.ProjectLive.Index do page_title: "New Project", active_menu_item: :projects, project: %Lightning.Projects.Project{project_users: []}, - users: Lightning.Accounts.list_users(), + users: Accounts.list_users(), sort_key: "name", sort_direction: "asc", filter: "" @@ -90,15 +83,19 @@ defmodule LightningWeb.ProjectLive.Index do end defp apply_action(socket, :delete, %{"id" => id}) do + table_params = socket.assigns.table_params + page = Projects.list_projects_for_admin(table_params) + socket |> assign( page_title: "Projects", - active_menu_item: :settings, - projects: list_projects("", "name", "asc"), + active_menu_item: :projects, + page: page, + pagination_path: pagination_path(socket, table_params), project: Projects.get_project(id), - sort_key: "name", - sort_direction: "asc", - filter: "" + sort_key: table_params["sort"], + sort_direction: table_params["dir"], + filter: table_params["filter"] ) end @@ -113,7 +110,9 @@ defmodule LightningWeb.ProjectLive.Index do {:noreply, socket |> put_flash(:info, "Project deletion canceled") - |> push_patch(to: ~p"/settings/projects")} + |> push_patch( + to: Routes.project_index_path(socket, :index, socket.assigns.table_params) + )} end def handle_event("sort", %{"by" => sort_key}, socket) do @@ -124,40 +123,34 @@ defmodule LightningWeb.ProjectLive.Index do sort_key ) - projects = list_projects(socket.assigns.filter, sort_key, sort_direction) + params = + socket.assigns.table_params + |> Map.put("sort", sort_key) + |> Map.put("dir", sort_direction) + |> Map.put("page", "1") {:noreply, - assign(socket, - projects: projects, - sort_key: sort_key, - sort_direction: sort_direction - )} + push_patch(socket, to: Routes.project_index_path(socket, :index, params))} end def handle_event("filter", %{"value" => filter}, socket) do - projects = - list_projects( - filter, - socket.assigns.sort_key, - socket.assigns.sort_direction - ) + params = + socket.assigns.table_params + |> Map.put("filter", String.trim(filter)) + |> Map.put("page", "1") {:noreply, - assign(socket, - projects: projects, - filter: filter - )} + push_patch(socket, to: Routes.project_index_path(socket, :index, params))} end def handle_event("clear_filter", _params, socket) do - projects = - list_projects("", socket.assigns.sort_key, socket.assigns.sort_direction) + params = + socket.assigns.table_params + |> Map.put("filter", "") + |> Map.put("page", "1") {:noreply, - assign(socket, - projects: projects, - filter: "" - )} + push_patch(socket, to: Routes.project_index_path(socket, :index, params))} end def delete_action(assigns) do @@ -189,25 +182,67 @@ defmodule LightningWeb.ProjectLive.Index do """ end - defp list_projects(filter, sort_key, sort_direction) do - projects = list_projects_with_owners() + defp normalize_table_params(params) do + params = Map.new(params, fn {k, v} -> {to_string(k), v} end) - TableHelpers.filter_and_sort( - projects, - filter, - project_search_fields(), - sort_key, - sort_direction, - project_sort_map() - ) + %{ + "filter" => normalize_filter(Map.get(params, "filter")), + "sort" => normalize_sort(Map.get(params, "sort")), + "dir" => normalize_dir(Map.get(params, "dir")), + "page" => Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), + "page_size" => + Map.get(params, "page_size") + |> parse_positive_int(@default_page_size) + |> min(@max_page_size) + |> Integer.to_string() + } end - defp list_projects_with_owners do - from(p in Lightning.Projects.Project, - preload: [project_users: :user], - order_by: p.name - ) - |> Lightning.Repo.all() + defp normalize_sort(sort) when is_binary(sort) do + if sort in @allowed_sorts, do: sort, else: @default_sort + end + + defp normalize_sort(sort) when is_atom(sort) do + sort + |> Atom.to_string() + |> normalize_sort() + end + + defp normalize_sort(_), do: @default_sort + + defp normalize_dir(dir) when dir in ["asc", :asc], do: "asc" + defp normalize_dir(dir) when dir in ["desc", :desc], do: "desc" + defp normalize_dir(_), do: "asc" + + defp normalize_filter(nil), do: "" + + defp normalize_filter(filter) do + filter + |> to_string() + |> String.trim() + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, + do: value + + defp parse_positive_int(value, default) do + case Integer.parse(to_string(value || "")) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp pagination_path(socket, table_params) do + fn route_params -> + params = + route_params + |> Enum.into(%{}) + |> Map.merge(Map.take(table_params, ["filter", "sort", "dir", "page_size"])) + |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) + |> Map.new() + + Routes.project_index_path(socket, :index, params) + end end def get_project_owner_name(project) do diff --git a/lib/lightning_web/live/project_live/index.html.heex b/lib/lightning_web/live/project_live/index.html.heex index 9b900887fe1..6f0766f0be1 100644 --- a/lib/lightning_web/live/project_live/index.html.heex +++ b/lib/lightning_web/live/project_live/index.html.heex @@ -37,7 +37,7 @@ placeholder="Filter projects..." /> - <.table id="projects"> + <.table id="projects" page={@page} url={@pagination_path}> <:header> <.tr> <.th @@ -86,7 +86,7 @@ <:body> - <%= for project <- @projects do %> + <%= for project <- @page.entries do %> <.tr id={"project-row-#{project.id}"}> <.td class="max-w-80">{project.name} <.td>{Calendar.strftime(project.inserted_at, "%d %b %H:%M")} diff --git a/test/lightning_web/live/project_live_test.exs b/test/lightning_web/live/project_live_test.exs index 0a168c8d8e4..fce139ca753 100644 --- a/test/lightning_web/live/project_live_test.exs +++ b/test/lightning_web/live/project_live_test.exs @@ -456,6 +456,29 @@ defmodule LightningWeb.ProjectLiveTest do ]) end + test "projects index paginates and navigates pages", %{conn: conn} do + for i <- 1..25 do + insert(:project, name: "paged-project-#{i}") + end + + {:ok, index_live, html} = + live(conn, Routes.project_index_path(conn, :index)) + + assert html =~ "Showing" + assert has_element?(index_live, "nav[aria-label='Pagination'] a", "2") + + index_live + |> element("nav[aria-label='Pagination'] a", "2") + |> render_click() + + patched_path = assert_patch(index_live) + patch_uri = URI.parse(patched_path) + patch_params = URI.decode_query(patch_uri.query || "") + + assert patch_uri.path == "/settings/projects" + assert patch_params["page"] == "2" + end + test "sorting projects by created date works correctly", %{conn: conn} do # Create projects with different dates _project_old = From 7990c77884bdbfc1a69db59a0e8dced325dcfdab Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:37:49 -0500 Subject: [PATCH 06/12] test: cover admin list param fallback and safety --- test/lightning/projects_test.exs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/lightning/projects_test.exs b/test/lightning/projects_test.exs index 6518e6aab4b..8ba8536b05c 100644 --- a/test/lightning/projects_test.exs +++ b/test/lightning/projects_test.exs @@ -58,6 +58,22 @@ defmodule Lightning.ProjectsTest do assert Enum.map(page.entries, & &1.id) == [project.id] end + test "list_projects_for_admin/1 falls back to safe defaults for invalid params" do + project = insert(:project, name: "safe-project") + + page = + Projects.list_projects_for_admin(%{ + "sort" => "drop table projects", + "dir" => "sideways", + "page" => "0", + "page_size" => "1000" + }) + + assert page.page_number == 1 + assert page.page_size <= 100 + assert Enum.any?(page.entries, fn entry -> entry.id == project.id end) + end + test "list_project_credentials/1 returns all project_credentials for a project" do user = insert(:user) project = insert(:project, project_users: [%{user_id: user.id}]) From 1ee56494092a3629a801a45bc2b1ba8714167498 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:39:12 -0500 Subject: [PATCH 07/12] chore: format admin pagination changes --- lib/lightning/accounts.ex | 12 +++++++----- lib/lightning/projects.ex | 14 ++++++++++---- lib/lightning_web/live/project_live/index.ex | 7 +++++-- lib/lightning_web/live/user_live/index.ex | 3 ++- .../live/user_live/table_component.ex | 4 +++- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex index 75a18ccaa8a..ae18b4a920f 100644 --- a/lib/lightning/accounts.ex +++ b/lib/lightning/accounts.ex @@ -222,16 +222,16 @@ defmodule Lightning.Accounts do end defp order_admin_users(query, "enabled", "asc"), - do: order_by(query, [u], [desc: u.disabled]) + do: order_by(query, [u], desc: u.disabled) defp order_admin_users(query, "enabled", "desc"), - do: order_by(query, [u], [asc: u.disabled]) + do: order_by(query, [u], asc: u.disabled) defp order_admin_users(query, "scheduled_deletion", "asc"), - do: order_by(query, [u], [asc_nulls_last: u.scheduled_deletion]) + do: order_by(query, [u], asc_nulls_last: u.scheduled_deletion) defp order_admin_users(query, "scheduled_deletion", "desc"), - do: order_by(query, [u], [desc_nulls_first: u.scheduled_deletion]) + do: order_by(query, [u], desc_nulls_first: u.scheduled_deletion) defp order_admin_users(query, "role", dir) do direction = dir_to_atom(dir) @@ -246,7 +246,9 @@ defmodule Lightning.Accounts do end defp normalize_admin_sort(sort) when is_binary(sort) do - if sort in @admin_user_allowed_sorts, do: sort, else: @admin_user_default_sort + if sort in @admin_user_allowed_sorts, + do: sort, + else: @admin_user_default_sort end defp normalize_admin_sort(sort) when is_atom(sort) do diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index 2565d19b34b..c7b7ecfcbf2 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -279,15 +279,18 @@ defmodule Lightning.Projects do [p, _pu, owner], ilike(p.name, ^search) or ilike(p.description, ^search) or - ilike(fragment("concat_ws(' ', ?, ?)", owner.first_name, owner.last_name), ^search) + ilike( + fragment("concat_ws(' ', ?, ?)", owner.first_name, owner.last_name), + ^search + ) ) end defp order_admin_projects(query, "scheduled_deletion", "asc"), - do: order_by(query, [p, _pu, _owner], [asc_nulls_last: p.scheduled_deletion]) + do: order_by(query, [p, _pu, _owner], asc_nulls_last: p.scheduled_deletion) defp order_admin_projects(query, "scheduled_deletion", "desc"), - do: order_by(query, [p, _pu, _owner], [desc_nulls_first: p.scheduled_deletion]) + do: order_by(query, [p, _pu, _owner], desc_nulls_first: p.scheduled_deletion) defp order_admin_projects(query, "owner", dir) do direction = project_dir_to_atom(dir) @@ -295,7 +298,10 @@ defmodule Lightning.Projects do order_by( query, [_p, _pu, owner], - [{^direction, fragment("concat_ws(' ', ?, ?)", owner.first_name, owner.last_name)}] + [ + {^direction, + fragment("concat_ws(' ', ?, ?)", owner.first_name, owner.last_name)} + ] ) end diff --git a/lib/lightning_web/live/project_live/index.ex b/lib/lightning_web/live/project_live/index.ex index 99c450cec9f..94621ad3824 100644 --- a/lib/lightning_web/live/project_live/index.ex +++ b/lib/lightning_web/live/project_live/index.ex @@ -189,7 +189,8 @@ defmodule LightningWeb.ProjectLive.Index do "filter" => normalize_filter(Map.get(params, "filter")), "sort" => normalize_sort(Map.get(params, "sort")), "dir" => normalize_dir(Map.get(params, "dir")), - "page" => Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), + "page" => + Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), "page_size" => Map.get(params, "page_size") |> parse_positive_int(@default_page_size) @@ -237,7 +238,9 @@ defmodule LightningWeb.ProjectLive.Index do params = route_params |> Enum.into(%{}) - |> Map.merge(Map.take(table_params, ["filter", "sort", "dir", "page_size"])) + |> Map.merge( + Map.take(table_params, ["filter", "sort", "dir", "page_size"]) + ) |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) |> Map.new() diff --git a/lib/lightning_web/live/user_live/index.ex b/lib/lightning_web/live/user_live/index.ex index 6f70a081bcf..d6b1f78a666 100644 --- a/lib/lightning_web/live/user_live/index.ex +++ b/lib/lightning_web/live/user_live/index.ex @@ -82,7 +82,8 @@ defmodule LightningWeb.UserLive.Index do "filter" => normalize_filter(Map.get(params, "filter")), "sort" => normalize_sort(Map.get(params, "sort")), "dir" => normalize_dir(Map.get(params, "dir")), - "page" => Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), + "page" => + Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), "page_size" => Map.get(params, "page_size") |> parse_positive_int(@default_page_size) diff --git a/lib/lightning_web/live/user_live/table_component.ex b/lib/lightning_web/live/user_live/table_component.ex index d5902efdc8a..9fed5903c96 100644 --- a/lib/lightning_web/live/user_live/table_component.ex +++ b/lib/lightning_web/live/user_live/table_component.ex @@ -111,7 +111,9 @@ defmodule LightningWeb.UserLive.TableComponent do params = route_params |> Enum.into(%{}) - |> Map.merge(Map.take(table_params, ["filter", "sort", "dir", "page_size"])) + |> Map.merge( + Map.take(table_params, ["filter", "sort", "dir", "page_size"]) + ) |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) |> Map.new() From fcf4b25b52435742da51e1e0aa9be36fbba6e292 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 11:46:02 -0500 Subject: [PATCH 08/12] docs: update changelog for superuser list pagination --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb78d7feed6..e9617ff6a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to - Editors can now provision and merge sandboxes; merge checks editor+ role on the target project [#4384](https://github.com/OpenFn/lightning/issues/4384) +- Superuser users and projects settings lists now use server-side pagination, + sorting, and search for better performance + [#2913](https://github.com/OpenFn/lightning/issues/2913) - Show specific workflow names in sandbox merge dialog when target project has diverged, instead of generic warning message [#4001](https://github.com/OpenFn/lightning/issues/4001) From 67ca8dbb69199a73f60717563480ca8c36c1cce6 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 26 Feb 2026 12:17:09 -0500 Subject: [PATCH 09/12] fix: address PR review feedback on admin list queries --- lib/lightning/accounts.ex | 2 +- .../live/user_live/table_component.ex | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex index ae18b4a920f..8b9bf581609 100644 --- a/lib/lightning/accounts.ex +++ b/lib/lightning/accounts.ex @@ -216,7 +216,7 @@ defmodule Lightning.Accounts do [u], ilike(u.first_name, ^search) or ilike(u.last_name, ^search) or - ilike(u.email, ^search) or + ilike(fragment("?::text", u.email), ^search) or ilike(fragment("?::text", u.role), ^search) ) end diff --git a/lib/lightning_web/live/user_live/table_component.ex b/lib/lightning_web/live/user_live/table_component.ex index 9fed5903c96..4aead3c005a 100644 --- a/lib/lightning_web/live/user_live/table_component.ex +++ b/lib/lightning_web/live/user_live/table_component.ex @@ -37,12 +37,10 @@ defmodule LightningWeb.UserLive.TableComponent do @impl true def mount(socket) do - page = Accounts.list_users_for_admin(@default_table_params) - {:ok, socket |> assign(:table_params, @default_table_params) - |> assign_table_state(@default_table_params, page)} + |> assign_table_state(@default_table_params, empty_page())} end @impl true @@ -106,6 +104,16 @@ defmodule LightningWeb.UserLive.TableComponent do ) end + defp empty_page do + %Scrivener.Page{ + entries: [], + page_number: 1, + page_size: String.to_integer(@default_table_params["page_size"]), + total_entries: 0, + total_pages: 1 + } + end + defp pagination_path(socket, table_params) do fn route_params -> params = From 5ab22f0c357b8c9dabec8f8118216168ea836a60 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Sat, 7 Mar 2026 13:33:00 -0500 Subject: [PATCH 10/12] index migration removed, embedded schemas added, and the admin list queries/live views refactored to use them --- lib/lightning/accounts.ex | 83 ++----------------- lib/lightning/projects.ex | 81 ++---------------- lib/lightning_web/live/project_live/index.ex | 71 +++------------- lib/lightning_web/live/user_live/index.ex | 55 +----------- .../live/user_live/table_component.ex | 19 ++--- ...26162000_add_admin_search_trgm_indexes.exs | 28 ------- 6 files changed, 33 insertions(+), 304 deletions(-) delete mode 100644 priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex index 8b9bf581609..8930dc59c7a 100644 --- a/lib/lightning/accounts.ex +++ b/lib/lightning/accounts.ex @@ -11,6 +11,7 @@ defmodule Lightning.Accounts do alias Ecto.Changeset alias Ecto.Multi + alias Lightning.Accounts.AdminSearchParams alias Lightning.Accounts.Events alias Lightning.Accounts.User alias Lightning.Accounts.UserBackupCode @@ -114,29 +115,18 @@ defmodule Lightning.Accounts do Repo.all(User) end - @admin_user_default_sort "email" - @admin_user_allowed_sorts ~w(first_name last_name email role enabled support_user scheduled_deletion) - @admin_user_default_page_size 10 - @admin_user_max_page_size 100 - @doc """ Returns a paginated list of users for the superuser admin table with server-side filtering and sorting. """ @spec list_users_for_admin(map()) :: Scrivener.Page.t() def list_users_for_admin(params \\ %{}) do - %{ - filter: filter, - sort: sort, - dir: dir, - page: page, - page_size: page_size - } = normalize_admin_user_params(params) + params = AdminSearchParams.new(params) User - |> filter_admin_users(filter) - |> order_admin_users(sort, dir) - |> Repo.paginate(page: page, page_size: page_size) + |> filter_admin_users(params.filter) + |> order_admin_users(params.sort, params.dir) + |> Repo.paginate(AdminSearchParams.pagination_opts(params)) end @doc """ @@ -185,27 +175,6 @@ defmodule Lightning.Accounts do if User.valid_password?(user, password), do: user end - defp normalize_admin_user_params(params) do - params = stringify_param_keys(params) - - sort = normalize_admin_sort(Map.get(params, "sort")) - dir = normalize_admin_dir(Map.get(params, "dir")) - page = parse_positive_int(Map.get(params, "page"), 1) - - page_size = - Map.get(params, "page_size") - |> parse_positive_int(@admin_user_default_page_size) - |> min(@admin_user_max_page_size) - - %{ - filter: normalize_admin_filter(Map.get(params, "filter")), - sort: sort, - dir: dir, - page: page, - page_size: page_size - } - end - defp filter_admin_users(query, ""), do: query defp filter_admin_users(query, filter) do @@ -216,7 +185,7 @@ defmodule Lightning.Accounts do [u], ilike(u.first_name, ^search) or ilike(u.last_name, ^search) or - ilike(fragment("?::text", u.email), ^search) or + ilike(u.email, ^search) or ilike(fragment("?::text", u.role), ^search) ) end @@ -245,46 +214,6 @@ defmodule Lightning.Accounts do order_by(query, [u], [{^direction, field(u, ^sort_field)}]) end - defp normalize_admin_sort(sort) when is_binary(sort) do - if sort in @admin_user_allowed_sorts, - do: sort, - else: @admin_user_default_sort - end - - defp normalize_admin_sort(sort) when is_atom(sort) do - sort - |> Atom.to_string() - |> normalize_admin_sort() - end - - defp normalize_admin_sort(_), do: @admin_user_default_sort - - defp normalize_admin_dir(dir) when dir in ["asc", :asc], do: "asc" - defp normalize_admin_dir(dir) when dir in ["desc", :desc], do: "desc" - defp normalize_admin_dir(_), do: "asc" - - defp normalize_admin_filter(nil), do: "" - - defp normalize_admin_filter(filter) do - filter - |> to_string() - |> String.trim() - end - - defp stringify_param_keys(params) when is_map(params) do - Map.new(params, fn {key, value} -> {to_string(key), value} end) - end - - defp parse_positive_int(value, _default) when is_integer(value) and value > 0, - do: value - - defp parse_positive_int(value, default) do - case Integer.parse(to_string(value || "")) do - {int, ""} when int > 0 -> int - _ -> default - end - end - defp dir_to_atom("asc"), do: :asc defp dir_to_atom("desc"), do: :desc diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index c7b7ecfcbf2..cd51df22f9a 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -17,6 +17,7 @@ defmodule Lightning.Projects do alias Lightning.ExportUtils alias Lightning.Invocation.Dataclip alias Lightning.Invocation.Step + alias Lightning.Projects.AdminSearchParams alias Lightning.Projects alias Lightning.Projects.Audit alias Lightning.Projects.Events @@ -191,32 +192,21 @@ defmodule Lightning.Projects do Repo.all(from(p in Project, order_by: p.name)) end - @admin_project_default_sort "name" - @admin_project_allowed_sorts ~w(name inserted_at description owner scheduled_deletion) - @admin_project_default_page_size 10 - @admin_project_max_page_size 100 - @doc """ Returns a paginated list of projects for the superuser admin table with server-side filtering and sorting. """ @spec list_projects_for_admin(map()) :: Scrivener.Page.t() def list_projects_for_admin(params \\ %{}) do - %{ - filter: filter, - sort: sort, - dir: dir, - page: page, - page_size: page_size - } = normalize_admin_project_params(params) + params = AdminSearchParams.new(params) Project |> join(:left, [p], pu in assoc(p, :project_users), on: pu.role == :owner) |> join(:left, [_p, pu], owner in assoc(pu, :user)) |> preload([_p, _pu, _owner], project_users: :user) - |> filter_admin_projects(filter) - |> order_admin_projects(sort, dir) - |> Repo.paginate(page: page, page_size: page_size) + |> filter_admin_projects(params.filter) + |> order_admin_projects(params.sort, params.dir) + |> Repo.paginate(AdminSearchParams.pagination_opts(params)) end @doc """ @@ -248,27 +238,6 @@ defmodule Lightning.Projects do end end - defp normalize_admin_project_params(params) do - params = stringify_param_keys(params) - - sort = normalize_admin_project_sort(Map.get(params, "sort")) - dir = normalize_admin_project_dir(Map.get(params, "dir")) - page = parse_positive_int(Map.get(params, "page"), 1) - - page_size = - Map.get(params, "page_size") - |> parse_positive_int(@admin_project_default_page_size) - |> min(@admin_project_max_page_size) - - %{ - filter: normalize_admin_project_filter(Map.get(params, "filter")), - sort: sort, - dir: dir, - page: page, - page_size: page_size - } - end - defp filter_admin_projects(query, ""), do: query defp filter_admin_projects(query, filter) do @@ -312,46 +281,6 @@ defmodule Lightning.Projects do order_by(query, [p, _pu, _owner], [{^direction, field(p, ^sort_field)}]) end - defp normalize_admin_project_sort(sort) when is_binary(sort) do - if sort in @admin_project_allowed_sorts, - do: sort, - else: @admin_project_default_sort - end - - defp normalize_admin_project_sort(sort) when is_atom(sort) do - sort - |> Atom.to_string() - |> normalize_admin_project_sort() - end - - defp normalize_admin_project_sort(_), do: @admin_project_default_sort - - defp normalize_admin_project_dir(dir) when dir in ["asc", :asc], do: "asc" - defp normalize_admin_project_dir(dir) when dir in ["desc", :desc], do: "desc" - defp normalize_admin_project_dir(_), do: "asc" - - defp normalize_admin_project_filter(nil), do: "" - - defp normalize_admin_project_filter(filter) do - filter - |> to_string() - |> String.trim() - end - - defp stringify_param_keys(params) when is_map(params) do - Map.new(params, fn {key, value} -> {to_string(key), value} end) - end - - defp parse_positive_int(value, _default) when is_integer(value) and value > 0, - do: value - - defp parse_positive_int(value, default) do - case Integer.parse(to_string(value || "")) do - {int, ""} when int > 0 -> int - _ -> default - end - end - defp project_dir_to_atom("asc"), do: :asc defp project_dir_to_atom("desc"), do: :desc diff --git a/lib/lightning_web/live/project_live/index.ex b/lib/lightning_web/live/project_live/index.ex index 94621ad3824..95e189cee11 100644 --- a/lib/lightning_web/live/project_live/index.ex +++ b/lib/lightning_web/live/project_live/index.ex @@ -7,14 +7,10 @@ defmodule LightningWeb.ProjectLive.Index do alias Lightning.Accounts alias Lightning.Policies.Permissions alias Lightning.Policies.Users + alias Lightning.Projects.AdminSearchParams alias Lightning.Projects alias LightningWeb.Live.Helpers.TableHelpers - @default_sort "name" - @allowed_sorts ~w(name inserted_at description owner scheduled_deletion) - @default_page_size 10 - @max_page_size 100 - @impl true def mount(_params, _session, socket) do can_access_admin_space = @@ -57,28 +53,32 @@ defmodule LightningWeb.ProjectLive.Index do end defp apply_action(socket, :edit, %{"id" => id}) do + default_table_params = AdminSearchParams.default_uri_params() + socket |> assign( page_title: "Edit Project", active_menu_item: :projects, project: Projects.get_project_with_users!(id), users: Accounts.list_users(), - sort_key: "name", - sort_direction: "asc", - filter: "" + sort_key: default_table_params["sort"], + sort_direction: default_table_params["dir"], + filter: default_table_params["filter"] ) end defp apply_action(socket, :new, _params) do + default_table_params = AdminSearchParams.default_uri_params() + socket |> assign( page_title: "New Project", active_menu_item: :projects, project: %Lightning.Projects.Project{project_users: []}, users: Accounts.list_users(), - sort_key: "name", - sort_direction: "asc", - filter: "" + sort_key: default_table_params["sort"], + sort_direction: default_table_params["dir"], + filter: default_table_params["filter"] ) end @@ -183,54 +183,7 @@ defmodule LightningWeb.ProjectLive.Index do end defp normalize_table_params(params) do - params = Map.new(params, fn {k, v} -> {to_string(k), v} end) - - %{ - "filter" => normalize_filter(Map.get(params, "filter")), - "sort" => normalize_sort(Map.get(params, "sort")), - "dir" => normalize_dir(Map.get(params, "dir")), - "page" => - Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), - "page_size" => - Map.get(params, "page_size") - |> parse_positive_int(@default_page_size) - |> min(@max_page_size) - |> Integer.to_string() - } - end - - defp normalize_sort(sort) when is_binary(sort) do - if sort in @allowed_sorts, do: sort, else: @default_sort - end - - defp normalize_sort(sort) when is_atom(sort) do - sort - |> Atom.to_string() - |> normalize_sort() - end - - defp normalize_sort(_), do: @default_sort - - defp normalize_dir(dir) when dir in ["asc", :asc], do: "asc" - defp normalize_dir(dir) when dir in ["desc", :desc], do: "desc" - defp normalize_dir(_), do: "asc" - - defp normalize_filter(nil), do: "" - - defp normalize_filter(filter) do - filter - |> to_string() - |> String.trim() - end - - defp parse_positive_int(value, _default) when is_integer(value) and value > 0, - do: value - - defp parse_positive_int(value, default) do - case Integer.parse(to_string(value || "")) do - {int, ""} when int > 0 -> int - _ -> default - end + AdminSearchParams.to_uri_params(params) end defp pagination_path(socket, table_params) do diff --git a/lib/lightning_web/live/user_live/index.ex b/lib/lightning_web/live/user_live/index.ex index d6b1f78a666..c74d3672e1c 100644 --- a/lib/lightning_web/live/user_live/index.ex +++ b/lib/lightning_web/live/user_live/index.ex @@ -5,14 +5,10 @@ defmodule LightningWeb.UserLive.Index do use LightningWeb, :live_view alias Lightning.Accounts + alias Lightning.Accounts.AdminSearchParams alias Lightning.Policies.Permissions alias Lightning.Policies.Users - @default_sort "email" - @allowed_sorts ~w(first_name last_name email role enabled support_user scheduled_deletion) - @default_page_size 10 - @max_page_size 100 - @impl true def mount(_params, _session, socket) do can_access_admin_space = @@ -76,53 +72,6 @@ defmodule LightningWeb.UserLive.Index do end defp normalize_table_params(params) do - params = Map.new(params, fn {k, v} -> {to_string(k), v} end) - - %{ - "filter" => normalize_filter(Map.get(params, "filter")), - "sort" => normalize_sort(Map.get(params, "sort")), - "dir" => normalize_dir(Map.get(params, "dir")), - "page" => - Map.get(params, "page") |> parse_positive_int(1) |> Integer.to_string(), - "page_size" => - Map.get(params, "page_size") - |> parse_positive_int(@default_page_size) - |> min(@max_page_size) - |> Integer.to_string() - } - end - - defp normalize_sort(sort) when is_binary(sort) do - if sort in @allowed_sorts, do: sort, else: @default_sort - end - - defp normalize_sort(sort) when is_atom(sort) do - sort - |> Atom.to_string() - |> normalize_sort() - end - - defp normalize_sort(_), do: @default_sort - - defp normalize_dir(dir) when dir in ["asc", :asc], do: "asc" - defp normalize_dir(dir) when dir in ["desc", :desc], do: "desc" - defp normalize_dir(_), do: "asc" - - defp normalize_filter(nil), do: "" - - defp normalize_filter(filter) do - filter - |> to_string() - |> String.trim() - end - - defp parse_positive_int(value, _default) when is_integer(value) and value > 0, - do: value - - defp parse_positive_int(value, default) do - case Integer.parse(to_string(value || "")) do - {int, ""} when int > 0 -> int - _ -> default - end + AdminSearchParams.to_uri_params(params) end end diff --git a/lib/lightning_web/live/user_live/table_component.ex b/lib/lightning_web/live/user_live/table_component.ex index 4aead3c005a..d587f372162 100644 --- a/lib/lightning_web/live/user_live/table_component.ex +++ b/lib/lightning_web/live/user_live/table_component.ex @@ -5,16 +5,9 @@ defmodule LightningWeb.UserLive.TableComponent do import LightningWeb.UserLive.Components alias Lightning.Accounts + alias Lightning.Accounts.AdminSearchParams alias LightningWeb.Live.Helpers.TableHelpers - @default_table_params %{ - "filter" => "", - "sort" => "email", - "dir" => "asc", - "page" => "1", - "page_size" => "10" - } - @impl true def render(assigns) do ~H""" @@ -37,10 +30,12 @@ defmodule LightningWeb.UserLive.TableComponent do @impl true def mount(socket) do + table_params = AdminSearchParams.default_uri_params() + {:ok, socket - |> assign(:table_params, @default_table_params) - |> assign_table_state(@default_table_params, empty_page())} + |> assign(:table_params, table_params) + |> assign_table_state(table_params, empty_page())} end @impl true @@ -105,10 +100,12 @@ defmodule LightningWeb.UserLive.TableComponent do end defp empty_page do + params = AdminSearchParams.new() + %Scrivener.Page{ entries: [], page_number: 1, - page_size: String.to_integer(@default_table_params["page_size"]), + page_size: params.page_size, total_entries: 0, total_pages: 1 } diff --git a/priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs b/priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs deleted file mode 100644 index eab307980ab..00000000000 --- a/priv/repo/migrations/20260226162000_add_admin_search_trgm_indexes.exs +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Lightning.Repo.Migrations.AddAdminSearchTrgmIndexes do - use Ecto.Migration - - @disable_ddl_transaction true - @disable_migration_lock true - - def up do - execute "CREATE EXTENSION IF NOT EXISTS pg_trgm" - - execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS users_first_name_trgm_idx ON users USING GIN (first_name gin_trgm_ops) WHERE first_name IS NOT NULL" - - execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS users_last_name_trgm_idx ON users USING GIN (last_name gin_trgm_ops) WHERE last_name IS NOT NULL" - - execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS users_email_trgm_idx ON users USING GIN ((email::text) gin_trgm_ops) WHERE email IS NOT NULL" - - execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS projects_name_trgm_idx ON projects USING GIN (name gin_trgm_ops) WHERE name IS NOT NULL" - - execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS projects_description_trgm_idx ON projects USING GIN (description gin_trgm_ops) WHERE description IS NOT NULL" - end - - def down do - execute "DROP INDEX CONCURRENTLY IF EXISTS users_first_name_trgm_idx" - execute "DROP INDEX CONCURRENTLY IF EXISTS users_last_name_trgm_idx" - execute "DROP INDEX CONCURRENTLY IF EXISTS users_email_trgm_idx" - execute "DROP INDEX CONCURRENTLY IF EXISTS projects_name_trgm_idx" - execute "DROP INDEX CONCURRENTLY IF EXISTS projects_description_trgm_idx" - end -end From a560814d996db8adc237dd3c686daf8382b4cb5b Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Sat, 7 Mar 2026 13:38:36 -0500 Subject: [PATCH 11/12] add new schema --- lib/lightning/accounts/admin_search_params.ex | 118 ++++++++++++++++++ lib/lightning/projects/admin_search_params.ex | 118 ++++++++++++++++++ .../accounts/admin_search_params_test.exs | 63 ++++++++++ .../projects/admin_search_params_test.exs | 63 ++++++++++ 4 files changed, 362 insertions(+) create mode 100644 lib/lightning/accounts/admin_search_params.ex create mode 100644 lib/lightning/projects/admin_search_params.ex create mode 100644 test/lightning/accounts/admin_search_params_test.exs create mode 100644 test/lightning/projects/admin_search_params_test.exs diff --git a/lib/lightning/accounts/admin_search_params.ex b/lib/lightning/accounts/admin_search_params.ex new file mode 100644 index 00000000000..ee22c12f558 --- /dev/null +++ b/lib/lightning/accounts/admin_search_params.ex @@ -0,0 +1,118 @@ +defmodule Lightning.Accounts.AdminSearchParams do + @moduledoc """ + Normalized query params for the superuser users table. + """ + + use Lightning.Schema + + @primary_key false + + @default_sort "email" + @allowed_sorts ~w(first_name last_name email role enabled support_user scheduled_deletion) + @default_page 1 + @default_page_size 10 + @max_page_size 100 + + @type t :: %__MODULE__{ + filter: String.t(), + sort: String.t(), + dir: String.t(), + page: pos_integer(), + page_size: pos_integer() + } + + embedded_schema do + field :filter, :string, default: "" + field :sort, :string, default: @default_sort + field :dir, :string, default: "asc" + field :page, :integer, default: @default_page + field :page_size, :integer, default: @default_page_size + end + + def new(params \\ %{}) + def new(%__MODULE__{} = params), do: params + def new(nil), do: new(%{}) + + def new(params) when is_map(params) do + params = stringify_param_keys(params) + + %__MODULE__{} + |> cast( + %{ + "filter" => normalize_filter(Map.get(params, "filter")), + "sort" => normalize_sort(Map.get(params, "sort")), + "dir" => normalize_dir(Map.get(params, "dir")), + "page" => parse_positive_int(Map.get(params, "page"), @default_page), + "page_size" => + Map.get(params, "page_size") + |> parse_positive_int(@default_page_size) + |> min(@max_page_size) + }, + [:filter, :sort, :dir, :page, :page_size] + ) + |> apply_action!(:validate) + end + + def default_uri_params do + new() + |> to_uri_params() + end + + def pagination_opts(%__MODULE__{} = params) do + [page: params.page, page_size: params.page_size] + end + + def to_uri_params(%__MODULE__{} = params) do + %{ + "filter" => params.filter, + "sort" => params.sort, + "dir" => params.dir, + "page" => Integer.to_string(params.page), + "page_size" => Integer.to_string(params.page_size) + } + end + + def to_uri_params(params) when is_map(params) do + params + |> new() + |> to_uri_params() + end + + defp normalize_sort(sort) when is_binary(sort) do + if sort in @allowed_sorts, do: sort, else: @default_sort + end + + defp normalize_sort(sort) when is_atom(sort) do + sort + |> Atom.to_string() + |> normalize_sort() + end + + defp normalize_sort(_), do: @default_sort + + defp normalize_dir(dir) when dir in ["asc", :asc], do: "asc" + defp normalize_dir(dir) when dir in ["desc", :desc], do: "desc" + defp normalize_dir(_), do: "asc" + + defp normalize_filter(nil), do: "" + + defp normalize_filter(filter) do + filter + |> to_string() + |> String.trim() + end + + defp stringify_param_keys(params) do + Map.new(params, fn {key, value} -> {to_string(key), value} end) + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, + do: value + + defp parse_positive_int(value, default) do + case Integer.parse(to_string(value || "")) do + {int, ""} when int > 0 -> int + _ -> default + end + end +end diff --git a/lib/lightning/projects/admin_search_params.ex b/lib/lightning/projects/admin_search_params.ex new file mode 100644 index 00000000000..4554012b5e4 --- /dev/null +++ b/lib/lightning/projects/admin_search_params.ex @@ -0,0 +1,118 @@ +defmodule Lightning.Projects.AdminSearchParams do + @moduledoc """ + Normalized query params for the superuser projects table. + """ + + use Lightning.Schema + + @primary_key false + + @default_sort "name" + @allowed_sorts ~w(name inserted_at description owner scheduled_deletion) + @default_page 1 + @default_page_size 10 + @max_page_size 100 + + @type t :: %__MODULE__{ + filter: String.t(), + sort: String.t(), + dir: String.t(), + page: pos_integer(), + page_size: pos_integer() + } + + embedded_schema do + field :filter, :string, default: "" + field :sort, :string, default: @default_sort + field :dir, :string, default: "asc" + field :page, :integer, default: @default_page + field :page_size, :integer, default: @default_page_size + end + + def new(params \\ %{}) + def new(%__MODULE__{} = params), do: params + def new(nil), do: new(%{}) + + def new(params) when is_map(params) do + params = stringify_param_keys(params) + + %__MODULE__{} + |> cast( + %{ + "filter" => normalize_filter(Map.get(params, "filter")), + "sort" => normalize_sort(Map.get(params, "sort")), + "dir" => normalize_dir(Map.get(params, "dir")), + "page" => parse_positive_int(Map.get(params, "page"), @default_page), + "page_size" => + Map.get(params, "page_size") + |> parse_positive_int(@default_page_size) + |> min(@max_page_size) + }, + [:filter, :sort, :dir, :page, :page_size] + ) + |> apply_action!(:validate) + end + + def default_uri_params do + new() + |> to_uri_params() + end + + def pagination_opts(%__MODULE__{} = params) do + [page: params.page, page_size: params.page_size] + end + + def to_uri_params(%__MODULE__{} = params) do + %{ + "filter" => params.filter, + "sort" => params.sort, + "dir" => params.dir, + "page" => Integer.to_string(params.page), + "page_size" => Integer.to_string(params.page_size) + } + end + + def to_uri_params(params) when is_map(params) do + params + |> new() + |> to_uri_params() + end + + defp normalize_sort(sort) when is_binary(sort) do + if sort in @allowed_sorts, do: sort, else: @default_sort + end + + defp normalize_sort(sort) when is_atom(sort) do + sort + |> Atom.to_string() + |> normalize_sort() + end + + defp normalize_sort(_), do: @default_sort + + defp normalize_dir(dir) when dir in ["asc", :asc], do: "asc" + defp normalize_dir(dir) when dir in ["desc", :desc], do: "desc" + defp normalize_dir(_), do: "asc" + + defp normalize_filter(nil), do: "" + + defp normalize_filter(filter) do + filter + |> to_string() + |> String.trim() + end + + defp stringify_param_keys(params) do + Map.new(params, fn {key, value} -> {to_string(key), value} end) + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, + do: value + + defp parse_positive_int(value, default) do + case Integer.parse(to_string(value || "")) do + {int, ""} when int > 0 -> int + _ -> default + end + end +end diff --git a/test/lightning/accounts/admin_search_params_test.exs b/test/lightning/accounts/admin_search_params_test.exs new file mode 100644 index 00000000000..bc30f916d76 --- /dev/null +++ b/test/lightning/accounts/admin_search_params_test.exs @@ -0,0 +1,63 @@ +defmodule Lightning.Accounts.AdminSearchParamsTest do + use ExUnit.Case, async: true + + alias Lightning.Accounts.AdminSearchParams + + describe "new/1" do + test "normalizes invalid values to safe defaults" do + loaded? = Code.ensure_loaded?(AdminSearchParams) + assert loaded? + + params = + if loaded? do + AdminSearchParams.new(%{ + "filter" => " alice ", + "sort" => "not_a_column", + "dir" => "sideways", + "page" => "-10", + "page_size" => "1000" + }) + else + %{} + end + + assert Map.take(params, [:filter, :sort, :dir, :page, :page_size]) == %{ + filter: "alice", + sort: "email", + dir: "asc", + page: 1, + page_size: 100 + } + end + end + + describe "to_uri_params/1" do + test "serializes normalized params for liveview routes" do + loaded? = Code.ensure_loaded?(AdminSearchParams) + assert loaded? + + uri_params = + if loaded? do + %{ + "filter" => " bob ", + "sort" => "role", + "dir" => "desc", + "page" => "3", + "page_size" => "25" + } + |> AdminSearchParams.new() + |> AdminSearchParams.to_uri_params() + else + %{} + end + + assert uri_params == %{ + "filter" => "bob", + "sort" => "role", + "dir" => "desc", + "page" => "3", + "page_size" => "25" + } + end + end +end diff --git a/test/lightning/projects/admin_search_params_test.exs b/test/lightning/projects/admin_search_params_test.exs new file mode 100644 index 00000000000..58e29eab3a9 --- /dev/null +++ b/test/lightning/projects/admin_search_params_test.exs @@ -0,0 +1,63 @@ +defmodule Lightning.Projects.AdminSearchParamsTest do + use ExUnit.Case, async: true + + alias Lightning.Projects.AdminSearchParams + + describe "new/1" do + test "normalizes invalid values to safe defaults" do + loaded? = Code.ensure_loaded?(AdminSearchParams) + assert loaded? + + params = + if loaded? do + AdminSearchParams.new(%{ + "filter" => " alpha ", + "sort" => "drop table projects", + "dir" => "sideways", + "page" => "0", + "page_size" => "1000" + }) + else + %{} + end + + assert Map.take(params, [:filter, :sort, :dir, :page, :page_size]) == %{ + filter: "alpha", + sort: "name", + dir: "asc", + page: 1, + page_size: 100 + } + end + end + + describe "to_uri_params/1" do + test "serializes normalized params for liveview routes" do + loaded? = Code.ensure_loaded?(AdminSearchParams) + assert loaded? + + uri_params = + if loaded? do + %{ + "filter" => " jane ", + "sort" => "owner", + "dir" => "desc", + "page" => "4", + "page_size" => "25" + } + |> AdminSearchParams.new() + |> AdminSearchParams.to_uri_params() + else + %{} + end + + assert uri_params == %{ + "filter" => "jane", + "sort" => "owner", + "dir" => "desc", + "page" => "4", + "page_size" => "25" + } + end + end +end From d84db8bb58f0c8beb8b14475cec75419ed018d04 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Sun, 8 Mar 2026 01:07:20 -0500 Subject: [PATCH 12/12] fix credo violations --- lib/lightning/projects.ex | 2 +- lib/lightning_web/live/project_live/index.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index cd51df22f9a..443f1830413 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -17,8 +17,8 @@ defmodule Lightning.Projects do alias Lightning.ExportUtils alias Lightning.Invocation.Dataclip alias Lightning.Invocation.Step - alias Lightning.Projects.AdminSearchParams alias Lightning.Projects + alias Lightning.Projects.AdminSearchParams alias Lightning.Projects.Audit alias Lightning.Projects.Events alias Lightning.Projects.Project diff --git a/lib/lightning_web/live/project_live/index.ex b/lib/lightning_web/live/project_live/index.ex index 95e189cee11..5e30a64cdb2 100644 --- a/lib/lightning_web/live/project_live/index.ex +++ b/lib/lightning_web/live/project_live/index.ex @@ -7,8 +7,8 @@ defmodule LightningWeb.ProjectLive.Index do alias Lightning.Accounts alias Lightning.Policies.Permissions alias Lightning.Policies.Users - alias Lightning.Projects.AdminSearchParams alias Lightning.Projects + alias Lightning.Projects.AdminSearchParams alias LightningWeb.Live.Helpers.TableHelpers @impl true