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) diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex index c4a45adf1ea..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,6 +115,20 @@ defmodule Lightning.Accounts do Repo.all(User) end + @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 + params = AdminSearchParams.new(params) + + User + |> filter_admin_users(params.filter) + |> order_admin_users(params.sort, params.dir) + |> Repo.paginate(AdminSearchParams.pagination_opts(params)) + end + @doc """ Returns the list of users with the given emails """ @@ -160,6 +175,48 @@ defmodule Lightning.Accounts do if User.valid_password?(user, password), do: user 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 dir_to_atom("asc"), do: :asc + defp dir_to_atom("desc"), do: :desc + @doc """ Gets a single user. 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.ex b/lib/lightning/projects.ex index c806608e192..443f1830413 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -18,6 +18,7 @@ defmodule Lightning.Projects do alias Lightning.Invocation.Dataclip alias Lightning.Invocation.Step alias Lightning.Projects + alias Lightning.Projects.AdminSearchParams alias Lightning.Projects.Audit alias Lightning.Projects.Events alias Lightning.Projects.Project @@ -191,6 +192,23 @@ defmodule Lightning.Projects do Repo.all(from(p in Project, order_by: p.name)) end + @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 + 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(params.filter) + |> order_admin_projects(params.sort, params.dir) + |> Repo.paginate(AdminSearchParams.pagination_opts(params)) + end + @doc """ Lists all projects that have history retention """ @@ -220,6 +238,52 @@ defmodule Lightning.Projects do end 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 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/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/lib/lightning_web/live/project_live/index.ex b/lib/lightning_web/live/project_live/index.ex index 3339ff6b903..5e30a64cdb2 100644 --- a/lib/lightning_web/live/project_live/index.ex +++ b/lib/lightning_web/live/project_live/index.ex @@ -4,33 +4,13 @@ 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 Lightning.Projects.AdminSearchParams 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 - @impl true def mount(_params, _session, socket) do can_access_admin_space = @@ -48,57 +28,74 @@ 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 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: Lightning.Accounts.list_users(), - sort_key: "name", - sort_direction: "asc", - filter: "" + users: Accounts.list_users(), + 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: Lightning.Accounts.list_users(), - sort_key: "name", - sort_direction: "asc", - filter: "" + users: Accounts.list_users(), + sort_key: default_table_params["sort"], + sort_direction: default_table_params["dir"], + filter: default_table_params["filter"] ) 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,23 @@ defmodule LightningWeb.ProjectLive.Index do """ end - defp list_projects(filter, sort_key, sort_direction) do - projects = list_projects_with_owners() - - TableHelpers.filter_and_sort( - projects, - filter, - project_search_fields(), - sort_key, - sort_direction, - project_sort_map() - ) + defp normalize_table_params(params) do + AdminSearchParams.to_uri_params(params) 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 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/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..c74d3672e1c 100644 --- a/lib/lightning_web/live/user_live/index.ex +++ b/lib/lightning_web/live/user_live/index.ex @@ -5,6 +5,7 @@ defmodule LightningWeb.UserLive.Index do use LightningWeb, :live_view alias Lightning.Accounts + alias Lightning.Accounts.AdminSearchParams alias Lightning.Policies.Permissions alias Lightning.Policies.Users @@ -30,7 +31,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 +70,8 @@ defmodule LightningWeb.UserLive.Index do |> put_flash(:error, "Cancel user deletion failed")} end end + + defp normalize_table_params(params) do + AdminSearchParams.to_uri_params(params) + 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..d587f372162 100644 --- a/lib/lightning_web/live/user_live/table_component.ex +++ b/lib/lightning_web/live/user_live/table_component.ex @@ -5,6 +5,7 @@ defmodule LightningWeb.UserLive.TableComponent do import LightningWeb.UserLive.Components alias Lightning.Accounts + alias Lightning.Accounts.AdminSearchParams alias LightningWeb.Live.Helpers.TableHelpers @impl true @@ -16,7 +17,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 +30,23 @@ defmodule LightningWeb.UserLive.TableComponent do @impl true def mount(socket) do + table_params = AdminSearchParams.default_uri_params() + {:ok, - assign(socket, - users: list_users("", "email", "asc"), - sort_key: "email", - sort_direction: "asc", - filter: "" - )} + socket + |> assign(:table_params, table_params) + |> assign_table_state(table_params, empty_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 +58,71 @@ 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 + defp empty_page do + params = AdminSearchParams.new() + + %Scrivener.Page{ + entries: [], + page_number: 1, + page_size: params.page_size, + total_entries: 0, + total_pages: 1 } 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/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/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) 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 diff --git a/test/lightning/projects_test.exs b/test/lightning/projects_test.exs index deb038a5d65..8ba8536b05c 100644 --- a/test/lightning/projects_test.exs +++ b/test/lightning/projects_test.exs @@ -33,6 +33,47 @@ 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_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}]) 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 = 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")