Skip to content

Lesson 4 — Policy-driven UI

With the policy in place on the board view, we need to thread it down to KanbanColumn and KanbanCard so UI controls appear or disappear based on role.

Updating KanbanColumn

Add a policy prop and conditionally render edit/delete controls:

  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
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# app/components/kanban_column.rb
class Components::KanbanColumn < Components::Base
  prop :column, ::Column
  prop :policy, _Nilable(_Any), default: -> { nil }

  def view_template
    div(
      id:    dom_id(@column),
      class: "flex flex-col bg-surface-alt rounded-lg p-3 w-72 shrink-0",
      data:  { column_id: @column.id }
    ) do
      render_header
      render_cards
      render_add_card
    end
  end

  private

  def render_header
    div(
      class: "mb-3",
      data:  { controller: "column-form" }
    ) do
      render_column_display
      render_column_edit_form if can_manage?
    end
  end

  def render_column_display
    div(
      class: "flex items-center justify-between cursor-grab active:cursor-grabbing",
      data:  { column_handle: true, column_form_target: "display" }
    ) do
      h2(class: "font-semibold text-text text-sm flex-1") { @column.name }
      Badge(label: @column.cards.count.to_s)
      if can_manage?
        div(class: "flex gap-1 ml-2") do
          button(
            type:  "button",
            class: "text-text-muted hover:text-text p-1 rounded",
            data:  { action: "click->column-form#showForm" }
          ) do
            Icon(name: :pencil, class_name: "h-3 w-3")
          end
          render_delete_column_button
        end
      end
    end
  end

  def render_column_edit_form
    div(hidden: true, data: { column_form_target: "form" }) do
      render Views::Columns::ColumnForm.new(column: @column)
    end
  end

  def render_delete_column_button
    form_with(url: column_path(@column), method: :delete) do
      button(
        type:  "submit",
        class: "text-text-muted hover:text-danger p-1 rounded",
        data:  { turbo_confirm: delete_confirm_message }
      ) do
        Icon(name: :x_mark, class_name: "h-3 w-3")
      end
    end
  end

  def delete_confirm_message
    count = @column.cards.count
    if count == 0
      "Delete column \"#{@column.name}\"?"
    elsif count == 1
      "Delete column \"#{@column.name}\" and its 1 card?"
    else
      "Delete column \"#{@column.name}\" and all #{count} cards?"
    end
  end

  def render_cards
    div(
      id:    "cards_#{@column.id}",
      class: "flex flex-col gap-2 min-h-8 flex-1",
      data:  { card_list: @column.id }
    ) do
      @column.cards.ordered.each do |card|
        KanbanCard(card: card, policy: @policy)
      end
    end
  end

  def render_add_card
    div(data: { controller: "card-form" }) do
      button(
        type:  "button",
        class: "flex items-center gap-1 text-sm text-text-muted " \
               "hover:text-text rounded px-2 py-1 hover:bg-surface w-full mt-2",
        data:  {
          card_form_target: "link",
          action:           "click->card-form#showForm"
        }
      ) { "+ Add card" }

      div(hidden: true, class: "mt-2", data: { card_form_target: "form" }) do
        render Views::Cards::CardForm.new(
          card:   @column.cards.build,
          column: @column
        )
      end
    end
  end

  def can_manage?
    @policy&.manage_columns?
  end
end

Updating KanbanCard

Add a policy prop and conditionally show edit and delete controls:

 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
# app/components/kanban_card.rb
class Components::KanbanCard < Components::Base
  prop :card,   ::Card
  prop :policy, _Nilable(_Any), default: -> { nil }

  def view_template
    div(
      id:    dom_id(@card),
      class: "bg-surface rounded-md border border-border p-3 " \
             "shadow-sm hover:shadow-md transition-shadow",
      data:  { controller: "card-form", card_id: @card.id }
    ) do
      render_display
      render_edit_form if can_edit?
    end
  end

  private

  def render_display
    div(
      class: "flex items-start justify-between gap-2 cursor-grab " \
             "active:cursor-grabbing",
      data:  { card_form_target: "link" }
    ) do
      p(class: "text-sm text-text flex-1") { @card.title }
      if can_edit?
        div(class: "flex gap-1 shrink-0") do
          button(
            type:  "button",
            class: "text-text-muted hover:text-text p-1 rounded",
            data:  { action: "click->card-form#showForm" }
          ) do
            Icon(name: :pencil, class_name: "h-3 w-3")
          end
          render_delete_button
        end
      end
    end
  end

  def render_edit_form
    div(hidden: true, data: { card_form_target: "form" }) do
      render Views::Cards::CardForm.new(card: @card)
    end
  end

  def render_delete_button
    form_with(url: card_path(@card), method: :delete) do
      button(
        type:  "submit",
        class: "text-text-muted hover:text-danger p-1 rounded",
        data:  { turbo_confirm: "Delete this card?" }
      ) do
        Icon(name: :x_mark, class_name: "h-3 w-3")
      end
    end
  end

  def can_edit?
    @policy&.edit_card?
  end
end

Passing policy down from Views::Boards::Show

Update render_board to pass the policy to each column:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def render_board
  div(
    id:    dom_id(@board),
    class: "flex gap-4 overflow-x-auto pb-4",
    data:  {
      controller:              "board",
      board_cards_url_value:   cards_positions_path,
      board_columns_url_value: columns_positions_path
    }
  ) do
    @board.columns.ordered.each do |column|
      KanbanColumn(column: column, policy: @policy)
    end
    render_add_column if @policy.manage_columns?
  end
end

The “Add column” link only appears for admins. Members see the board in read/card-edit mode.

Add this section to Lesson 4, after the policy-driven UI section and before Lesson 5:


Member avatars on the board

Members should be able to see who has access to the board without needing admin rights. A compact row of avatars with names on hover gives this at a glance — distinct from the presence bar which shows who’s online right now.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# app/components/member_avatars.rb
class Components::MemberAvatars < Components::Base
  prop :board, ::Board

  def view_template
    div(class: "flex items-center -space-x-2") do
      @board.memberships.includes(:user).each do |membership|
        div(title: membership.user.name) do
          Avatar(name: membership.user.name, size: :sm)
        end
      end
    end
  end
end

The -space-x-2 class overlaps the avatars slightly — the standard stacked avatar pattern. Each avatar has a title attribute so hovering shows the member’s name.

Add it to render_header in Views::Boards::Show, visible to all members:

1
2
3
4
5
6
7
div(class: "flex items-center gap-3") do
  MemberAvatars(board: @board)
  PresenceBar(board: @board)
  Link(label: "Members", href: board_members_path(@board),
       variant: :secondary) if @policy.manage_members?
  render_board_actions
end

MemberAvatars shows everyone with access. PresenceBar shows who’s currently online. Together they answer both questions — who can see this board, and who’s here right now.