diff --git a/app/controllers/admin/scenarios_controller.rb b/app/controllers/admin/scenarios_controller.rb index 29290fd..25f57d2 100644 --- a/app/controllers/admin/scenarios_controller.rb +++ b/app/controllers/admin/scenarios_controller.rb @@ -6,18 +6,9 @@ def index @page = [ params[:page].to_i, 1 ].max @offset = (@page - 1) * PER_PAGE - scope = Current.organization.scenarios - .includes(:user) - .references(:user) - .order("users.name", "users.email_address", :name) - - if @query.present? - like = "%#{Scenario.sanitize_sql_like(@query.downcase)}%" - scope = scope.where("LOWER(users.name) LIKE :q OR LOWER(users.email_address) LIKE :q", q: like) - end - + scope = Current.organization.scenarios.search_by_user(@query).order(:name) @total = scope.count + @has_more = @offset + PER_PAGE < @total @scenarios = scope.limit(PER_PAGE).offset(@offset) - @has_more = @offset + @scenarios.size < @total end end diff --git a/app/controllers/organization_memberships_controller.rb b/app/controllers/organization_memberships_controller.rb index d4fa8d6..efe7c78 100644 --- a/app/controllers/organization_memberships_controller.rb +++ b/app/controllers/organization_memberships_controller.rb @@ -1,15 +1,25 @@ class OrganizationMembershipsController < ApplicationController + PER_PAGE = 50 + before_action :require_member_management before_action :set_membership, only: :update def index - @memberships = Current.organization.organization_memberships - .includes(:user).order(:created_at) + @query = params[:q].to_s.strip + @roles = Array(params[:roles]).select { |role| OrganizationMembership.roles.key?(role) } + @page = [ params[:page].to_i, 1 ].max + @offset = (@page - 1) * PER_PAGE + + scope = Current.organization.organization_memberships.search_by_user(@query) + scope = scope.where(role: @roles) if @roles.any? + @total = scope.count + @has_more = @offset + PER_PAGE < @total + @memberships = scope.limit(PER_PAGE).offset(@offset) end def update OrganizationMembership::RoleUpdater.new(@membership, actor: Current.user, role: params[:role]).call - redirect_to organization_memberships_path + redirect_to organization_memberships_path(q: params[:q], roles: params[:roles], page: params[:page]) end private diff --git a/app/models/concerns/user_searchable.rb b/app/models/concerns/user_searchable.rb new file mode 100644 index 0000000..3210900 --- /dev/null +++ b/app/models/concerns/user_searchable.rb @@ -0,0 +1,14 @@ +module UserSearchable + extend ActiveSupport::Concern + + included do + scope :search_by_user, ->(query) { + relation = includes(:user).references(:user).order("users.name", "users.email_address") + query = query.to_s.strip + next relation if query.blank? + + like = "%#{sanitize_sql_like(query.downcase)}%" + relation.where("LOWER(users.name) LIKE :q OR LOWER(users.email_address) LIKE :q", q: like) + } + end +end diff --git a/app/models/organization_membership.rb b/app/models/organization_membership.rb index 5c641b3..e4b4488 100644 --- a/app/models/organization_membership.rb +++ b/app/models/organization_membership.rb @@ -1,4 +1,6 @@ class OrganizationMembership < ApplicationRecord + include UserSearchable + belongs_to :user belongs_to :organization diff --git a/app/models/scenario.rb b/app/models/scenario.rb index 17c6bad..34eb10b 100644 --- a/app/models/scenario.rb +++ b/app/models/scenario.rb @@ -1,4 +1,6 @@ class Scenario < ApplicationRecord + include UserSearchable + belongs_to :organization belongs_to :user has_many :allocations, dependent: :destroy diff --git a/app/views/organization_memberships/index.html.erb b/app/views/organization_memberships/index.html.erb index c81ce8a..706fc05 100644 --- a/app/views/organization_memberships/index.html.erb +++ b/app/views/organization_memberships/index.html.erb @@ -17,31 +17,73 @@ <% end %> -
- +
+ <%= form_with url: organization_memberships_path, method: :get, + data: { controller: "debounced-form", action: "input->debounced-form#submit change->debounced-form#submit", + turbo_frame: "memberships_list", turbo_action: "advance" } do |form| %> +
+ <%= form.search_field :q, value: @query, + placeholder: "Filter by member…", autocomplete: "off", + class: "block w-64 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink placeholder:text-ink-faint focus:border-ink-soft focus:outline-none" %> +
+ <% %w[owner admin member].each do |role| %> + + <% end %> +
+
+ <% end %>
+ + <%= turbo_frame_tag "memberships_list", class: "mt-4 block", data: { turbo_action: "advance" } do %> +
+ +
+ + <% if @total.positive? %> +
+ Showing <%= @offset + 1 %>–<%= @offset + @memberships.size %> of <%= @total %> +
+ <% if @page > 1 %> + <%= link_to "← Prev", organization_memberships_path(q: @query, roles: @roles, page: @page - 1), class: "rounded border border-line px-2.5 py-1 text-ink-soft hover:bg-surface-soft hover:text-ink" %> + <% end %> + <% if @has_more %> + <%= link_to "Next →", organization_memberships_path(q: @query, roles: @roles, page: @page + 1), class: "rounded border border-line px-2.5 py-1 text-ink-soft hover:bg-surface-soft hover:text-ink" %> + <% end %> +
+
+ <% end %> + <% end %>
diff --git a/test/controllers/organization_memberships_controller_test.rb b/test/controllers/organization_memberships_controller_test.rb index 0d13442..e44c29e 100644 --- a/test/controllers/organization_memberships_controller_test.rb +++ b/test/controllers/organization_memberships_controller_test.rb @@ -29,6 +29,98 @@ class OrganizationMembershipsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to root_path end + test "lists every member in the organization" do + sign_in_as(@owner) + get organization_memberships_path + + assert_select "turbo-frame li", text: /#{@owner.email_address}/ + assert_select "turbo-frame li", text: /#{@admin.email_address}/ + assert_select "turbo-frame li", text: /#{@member.email_address}/ + end + + test "does not show members from another organization" do + sign_in_as(@owner) + get organization_memberships_path + + assert_select "turbo-frame li", text: /two@example.com/, count: 0 + end + + test "filters members by email on the server" do + sign_in_as(@owner) + get organization_memberships_path(q: "admin") + + assert_select "turbo-frame li", text: /#{@admin.email_address}/ + assert_select "turbo-frame li", text: /#{@owner.email_address}/, count: 0 + end + + test "filters members by user name on the server" do + @member.update!(name: "Zelda Fitzgerald") + sign_in_as(@owner) + get organization_memberships_path(q: "zelda") + + assert_select "turbo-frame li", text: /Zelda Fitzgerald/ + assert_select "turbo-frame li", text: /#{@owner.email_address}/, count: 0 + end + + test "filters members by role" do + sign_in_as(@owner) + get organization_memberships_path(roles: [ "admin" ]) + + assert_select "turbo-frame li", text: /#{@admin.email_address}/ + assert_select "turbo-frame li", text: /#{@owner.email_address}/, count: 0 + assert_select "turbo-frame li", text: /#{@member.email_address}/, count: 0 + end + + test "filters members by multiple roles" do + sign_in_as(@owner) + get organization_memberships_path(roles: [ "owner", "member" ]) + + assert_select "turbo-frame li", text: /#{@owner.email_address}/ + assert_select "turbo-frame li", text: /#{@member.email_address}/ + assert_select "turbo-frame li", text: /#{@admin.email_address}/, count: 0 + end + + test "combines the user search and role filters" do + sign_in_as(@owner) + get organization_memberships_path(q: "example.com", roles: [ "owner" ]) + + assert_select "turbo-frame li", text: /#{@owner.email_address}/ + assert_select "turbo-frame li", text: /#{@admin.email_address}/, count: 0 + end + + test "ignores unknown role values" do + sign_in_as(@owner) + get organization_memberships_path(roles: [ "superuser" ]) + + # No valid roles selected → no role filter applied, every member shows. + assert_select "turbo-frame li", text: /#{@owner.email_address}/ + assert_select "turbo-frame li", text: /#{@admin.email_address}/ + assert_select "turbo-frame li", text: /#{@member.email_address}/ + end + + test "shows an empty state when no members match" do + sign_in_as(@owner) + get organization_memberships_path(q: "nobody-matches-this") + + assert_select "turbo-frame li", text: /No members match/ + end + + test "paginates results" do + sign_in_as(@owner) + arlington = organizations(:arlington) + # Create more members than fit on one page so a second page exists. + (OrganizationMembershipsController::PER_PAGE + 5).times do |i| + user = User.create!(email_address: "bulk#{i}@example.com", confirmed_at: Time.current) + arlington.organization_memberships.create!(user: user, role: "member") + end + + get organization_memberships_path + assert_select "turbo-frame li", count: OrganizationMembershipsController::PER_PAGE + + get organization_memberships_path(page: 2) + assert_select "turbo-frame li", minimum: 1 + end + # update — promote test "an admin can promote a member to admin" do @@ -38,6 +130,12 @@ class OrganizationMembershipsControllerTest < ActionDispatch::IntegrationTest assert @member_membership.reload.admin? end + test "update preserves the active filter and page on redirect" do + sign_in_as(@admin) + patch organization_membership_path(@member_membership), params: { role: "admin", q: "passwordless", page: "2" } + assert_redirected_to organization_memberships_path(q: "passwordless", page: "2") + end + test "a member cannot promote anyone" do sign_in_as(@member) patch organization_membership_path(@admin_membership), params: { role: "admin" }