Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions app/controllers/admin/scenarios_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 13 additions & 3 deletions app/controllers/organization_memberships_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/models/concerns/user_searchable.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/models/organization_membership.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class OrganizationMembership < ApplicationRecord
include UserSearchable

belongs_to :user
belongs_to :organization

Expand Down
2 changes: 2 additions & 0 deletions app/models/scenario.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Scenario < ApplicationRecord
include UserSearchable

belongs_to :organization
belongs_to :user
has_many :allocations, dependent: :destroy
Expand Down
94 changes: 68 additions & 26 deletions app/views/organization_memberships/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,73 @@
<% end %>
</div>

<div class="mt-10 overflow-hidden rounded-2xl border border-line bg-surface shadow-sm">
<ul class="divide-y divide-line">
<% @memberships.each do |membership| %>
<li class="flex items-center justify-between gap-4 px-5 py-4">
<div class="min-w-0">
<p class="truncate font-medium text-ink"><%= membership.user.display_name %></p>
<% if membership.user.name.present? %>
<p class="truncate text-sm text-ink-soft"><%= membership.user.email_address %></p>
<% end %>
<span class="mt-1 inline-block rounded-full border border-line px-2 py-0.5 text-xs text-ink-soft">
<%= membership.role.capitalize %>
</span>
</div>

<div class="flex shrink-0 items-center gap-3 text-sm">
<% if membership.member? %>
<%= button_to "Make admin", organization_membership_path(membership), method: :patch, params: { role: "admin" },
class: "text-brand hover:underline cursor-pointer bg-transparent p-0" %>
<% elsif membership.admin? && owner? %>
<%= button_to "Make member", organization_membership_path(membership), method: :patch, params: { role: "member" },
class: "text-ink-soft hover:underline cursor-pointer bg-transparent p-0" %>
<% end %>
</div>
</li>
<% end %>
</ul>
<div class="mt-8">
<%= 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| %>
<div class="flex flex-wrap items-center gap-x-5 gap-y-3">
<%= 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" %>
<div class="flex items-center gap-4 text-sm text-ink-soft">
<% %w[owner admin member].each do |role| %>
<label class="inline-flex cursor-pointer items-center gap-1.5">
<%= check_box_tag "roles[]", role, @roles.include?(role),
id: "role_#{role}", class: "rounded border-line text-brand focus:ring-brand" %>
<%= role.capitalize %>
</label>
<% end %>
</div>
</div>
<% end %>
</div>

<%= turbo_frame_tag "memberships_list", class: "mt-4 block", data: { turbo_action: "advance" } do %>
<div class="overflow-hidden rounded-2xl border border-line bg-surface shadow-sm">
<ul class="divide-y divide-line">
<% @memberships.each do |membership| %>
<li class="flex items-center justify-between gap-4 px-5 py-4">
<div class="min-w-0">
<p class="truncate font-medium text-ink"><%= membership.user.display_name %></p>
<% if membership.user.name.present? %>
<p class="truncate text-sm text-ink-soft"><%= membership.user.email_address %></p>
<% end %>
<span class="mt-1 inline-block rounded-full border border-line px-2 py-0.5 text-xs text-ink-soft">
<%= membership.role.capitalize %>
</span>
</div>

<div class="flex shrink-0 items-center gap-3 text-sm">
<% if membership.member? %>
<%= button_to "Make admin", organization_membership_path(membership), method: :patch, params: { role: "admin", q: @query, roles: @roles, page: @page },
class: "text-brand hover:underline cursor-pointer bg-transparent p-0" %>
<% elsif membership.admin? && owner? %>
<%= button_to "Make member", organization_membership_path(membership), method: :patch, params: { role: "member", q: @query, roles: @roles, page: @page },
class: "text-ink-soft hover:underline cursor-pointer bg-transparent p-0" %>
<% end %>
</div>
</li>
<% end %>
<% if @total.zero? %>
<li class="px-5 py-12 text-center text-ink-faint">
<%= @query.present? || @roles.any? ? "No members match your filters." : "No members yet." %>
</li>
<% end %>
</ul>
</div>

<% if @total.positive? %>
<div class="mt-3 flex items-center justify-between text-xs text-ink-soft">
<span class="tabular-nums">Showing <%= @offset + 1 %>–<%= @offset + @memberships.size %> of <%= @total %></span>
<div class="flex items-center gap-2">
<% 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 %>
</div>
</div>
<% end %>
<% end %>
</div>
98 changes: 98 additions & 0 deletions test/controllers/organization_memberships_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" }
Expand Down
Loading