Skip to content

Lesson 3 — Members page

Routes

Add members routes nested under boards:

1
2
3
4
5
6
7
8
9
resources :boards do
  resources :members, only: [:index, :create, :destroy],
                      controller: "board_members"
  resources :columns, shallow: true,
                      only: [:create, :update, :destroy] do
    resources :cards, shallow: true,
                      only: [:create, :update, :destroy]
  end
end

BoardMembersController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# app/controllers/board_members_controller.rb
class BoardMembersController < ApplicationController
  before_action :set_board
  before_action :authorize_manage_members!

  def index
    render Views::Boards::Members::Index.new(
      board:       @board,
      memberships: @board.memberships.includes(:user).order(:created_at),
      policy:      policy_for(@board)
    )
  end

  def create
    email = params[:email].to_s.strip.downcase
    user  = User.find_by(email: email)

    if user.nil?
      redirect_to board_members_path(@board),
                  alert: "No user found with that email address."
    elsif @board.memberships.exists?(user: user)
      redirect_to board_members_path(@board),
                  alert: "#{user.name} is already a member."
    else
      @board.memberships.create!(user: user, role: :member)
      redirect_to board_members_path(@board),
                  notice: "#{user.name} added as a member."
    end
  end

  def destroy
    membership = @board.memberships.find(params[:id])

    if membership.admin?
      redirect_to board_members_path(@board),
                  alert: "Cannot remove the board owner."
    else
      membership.destroy
      redirect_to board_members_path(@board),
                  notice: "Member removed."
    end
  end

  private

  def set_board
    @board = Board.find(params[:board_id])
  end

  def authorize_manage_members!
    authorize! :manage_members?, @board
  end
end

The admin membership can’t be removed — the board must always have an owner. A member removing themselves could be a future enhancement.

Views::BoardMembers::Index

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# app/views/board_members/index.rb
class Views::BoardMembers::Index < Views::Base
  def page_title = "Members — #{@board.name}"

  def initialize(board:, memberships:, policy:)
    @board       = board
    @memberships = memberships
    @policy      = policy
  end

  def view_template
    Breadcrumb() do |b|
      b.item "Boards",    url: boards_path
      b.item @board.name, url: board_path(@board)
      b.item "Members"
    end

    div(class: "flex items-center justify-between mt-4 mb-6") do
      h1(class: "text-2xl font-bold text-text") { "Board members" }
    end

    render_invite_form
    render_members_list
  end

  private

  def render_invite_form
    Panel(title: "Add a member") do
      form_with(url: board_members_path(@board), class: "flex gap-3 items-end") do |f|
        div(class: "flex-1") do
          TextInput(
            form:        f,
            field:       :email,
            label:       "Email address",
            type:        "email",
            placeholder: "colleague@example.com"
          )
        end
        Button(label: "Add member", type: "submit")
      end
    end
  end

  def render_members_list
    Panel(title: "Current members") do
      div(class: "divide-y divide-border") do
        @memberships.each { |m| render_member_row(m) }
      end
    end
  end

  def render_member_row(membership)
    div(class: "flex items-center justify-between py-3") do
      div(class: "flex items-center gap-3") do
        Avatar(name: membership.user.name, size: :sm)
        div do
          p(class: "text-sm font-medium text-text") { membership.user.name }
          p(class: "text-xs text-text-muted") { membership.user.email }
        end
      end

      div(class: "flex items-center gap-3") do
        span(class: role_badge_classes(membership)) { membership.role.capitalize }

        unless membership.admin?
          form_with(
            url:    board_member_path(@board, membership),
            method: :delete
          ) do
            button(
              type:  "submit",
              class: "text-text-muted hover:text-danger p-1 rounded",
              data:  { turbo_confirm: "Remove #{membership.user.name}?" }
            ) do
              Icon(name: :x_mark, class_name: "h-4 w-4")
            end
          end
        end
      end
    end
  end

  def role_badge_classes(membership)
    base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
    if membership.admin?
      "#{base} bg-primary/10 text-primary"
    else
      "#{base} bg-surface-alt text-text-muted"
    end
  end
end

Link to members page from board

Update Views::Boards::Show to show the members link for admins. Pass the policy through from the controller:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# app/views/boards/show.rb
def initialize(board:, policy:)
  @board  = board
  @policy = policy
end

def render_header
  Breadcrumb() do |b|
    b.item "Boards", url: boards_path
    b.item @board.name
  end

  div(class: "flex items-center justify-between mt-4 mb-6") do
    h1(class: "text-2xl font-bold text-text") { @board.name }
    div(class: "flex items-center gap-3") do
      if @policy.manage_members?
        Link(label: "Members", href: board_members_path(@board),
             variant: :secondary)
      end
      render_board_actions
    end
  end
end

def render_board_actions
  return unless @policy.edit_board?
  Dropdown(label: "Board actions", align: :right) do |d|
    d.item "Edit board",   url: edit_board_path(@board)
    d.item "Delete board", url: board_path(@board), method: :delete if @policy.delete_board?
  end
end