Skip to content

Lesson 3 — The full board view

With morphing enabled, the board view is straightforward. The server renders columns and cards; Turbo morphs any changes. We don’t need explicit IDs for Turbo targeting — though we’ll use dom_id as good practice for Stimulus targeting and debugging.

dom_id in Phlex

dom_id generates stable, predictable IDs from ActiveRecord objects:

1
2
3
4
dom_id(board)       # => "board_1"
dom_id(column)      # => "column_3"
dom_id(card)        # => "card_42"
dom_id(Card.new)    # => "new_card"

Include the helper in Components::Base:

1
2
3
4
5
# app/components/base.rb
class Components::Base < Phlex::HTML
  include Phlex::Rails::Helpers::DOMID
  # ... other includes
end

With morphing, dom_id is primarily useful for Stimulus controllers to target specific elements, and for debugging — seeing id="card_42" in the DOM tells you exactly which record you’re looking at. It’s good practice, not a Turbo requirement.

Components::KanbanCard

 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
# app/components/kanban_card.rb
class Components::KanbanCard < Components::Base
  prop :card, Card

  def view_template
    div(
      id:    dom_id(@card),
      class: "bg-surface rounded-md border border-border p-3 " \
             "shadow-sm cursor-grab active:cursor-grabbing " \
             "hover:shadow-md transition-shadow",
      data:  { card_id: @card.id }
    ) do
      render_content
    end
  end

  private

  def render_content
    div(class: "flex items-start justify-between gap-2") do
      p(class: "text-sm text-text flex-1") { @card.title }
      render_actions
    end
  end

  def render_actions
    Dropdown(label: "⋯", align: :right) do |d|
      d.item "Edit",   url: edit_column_card_path(@card.column, @card)
      d.item "Delete", url: column_card_path(@card.column, @card),
                       method: :delete
    end
  end
end

Components::KanbanColumn

 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
# app/components/kanban_column.rb
class Components::KanbanColumn < Components::Base
  prop :column, Column

  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: "flex items-center justify-between mb-3") do
      h2(class: "font-semibold text-text text-sm") { @column.name }
      Badge(label: @column.cards.count.to_s)
    end
  end

  def render_cards
    div(class: "flex flex-col gap-2 min-h-8") do
      @column.cards.ordered.each do |card|
        KanbanCard(card: card)
      end
    end
  end

  def render_add_card
    div(class: "mt-2") do
      a(
        href:  new_column_card_path(@column),
        class: "flex items-center gap-1 text-sm text-text-muted " \
               "hover:text-text rounded px-2 py-1 hover:bg-surface"
      ) do
        plain "+ Add card"
      end
    end
  end
end

Views::Boards::Show

 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
# app/views/boards/show.rb
class Views::Boards::Show < Views::Base
  def page_title = @board.name

  def initialize(board:)
    @board = board
  end

  def view_template
    render_header
    render_board
  end

  private

  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 }
      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
      end
    end
  end

  def render_board
    div(
      id:    dom_id(@board),
      class: "flex gap-4 overflow-x-auto pb-4"
    ) do
      @board.columns.ordered.each do |column|
        KanbanColumn(column: column)
      end
      render_add_column
    end
  end

  def render_add_column
    div(class: "w-72 shrink-0") do
      a(
        href:  new_board_column_path(@board),
        class: "flex items-center gap-2 text-sm text-text-muted " \
               "hover:text-text bg-surface-alt/50 rounded-lg p-3 " \
               "border-2 border-dashed border-border " \
               "hover:border-border-strong"
      ) do
        plain "+ Add column"
      end
    end
  end
end

Routes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# config/routes.rb
Rails.application.routes.draw do
  if Rails.env.development?
    mount Lookbook::Engine, at: "/lookbook"
  end

  resources :boards do
    resources :columns, shallow: true do
      resources :cards, shallow: true
    end
  end

  root "boards#index"
end

Shallow nesting means column routes use /columns/:id rather than /boards/:board_id/columns/:id, and card routes use /cards/:id rather than the fully nested path. This keeps URLs clean and controllers simple.

Before we write any code

The board view is a composition of three components:

Views::Boards::Show
  └── KanbanColumn (one per column)
        └── KanbanCard (one per card)
              └── Dropdown (actions menu)

Show is responsible for the page structure and the “Add column” link.

KanbanColumn owns a single column — its header, its list of cards, and the “Add card” link.

KanbanCard owns a single card — its content and its actions dropdown.

Each component knows only about its own data; it receives what it needs as props and renders its own slice of the UI.

KanbanColumn and KanbanCard live in app/components/ — they are app-specific rendering components, but they don’t handle forms or submission logic.

Form components (CardForm, ColumnForm, BoardForm) live in app/views/ alongside the views that use them, following the same convention established with BoardForm in Module 4.

They inherit Components::Base rather than Views::Base since they render as fragments, not full pages.


A note on constant lookup in namespaced components

When you declare a prop inside Components::KanbanCard, Ruby resolves constant names relative to the current namespace. This means:

1
2
3
class Components::KanbanCard < Components::Base
  prop :card, Card   # Ruby looks for Components::Card first — wrong, should be the Card model!
end

If Components::Card exists (our UI Card component does), Literal finds it instead of the Card ActiveRecord model and raises a type mismatch. The fix is to anchor the constant to the root namespace with :::

1
prop :card, ::Card   # unambiguous — always resolves to the AR model

Apply :: to any prop that references an ActiveRecord model from within a namespaced component.

This includes ::Column, ::Board, and any other model props you add. It becomes second nature quickly — any time you’re inside Components:: and referencing a model, prefix it.

Model scopes

The board view orders columns and cards. Add scopes to the models:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# app/models/column.rb
class Column < ApplicationRecord
  belongs_to :board
  has_many :cards, dependent: :destroy

  scope :ordered, -> { order(:position) }
end

# app/models/card.rb
class Card < ApplicationRecord
  belongs_to :column

  scope :ordered, -> { order(:position) }
end

Users, boards, and membership

Before we wire up board creation, it’s worth understanding how the user-board relationship is modelled in KanbanFlow.

A user can relate to a board in two ways:

  • Ownership — the user created the board. Stored as user_id on the boards table.
  • Membership — the user has been invited to the board. Stored in the memberships join table.

These are different relationships and need different associations. The User model needs both:

1
2
3
4
# app/models/user.rb
has_many :owned_boards, class_name: "Board", foreign_key: :user_id, dependent: :destroy
has_many :memberships, dependent: :destroy
has_many :boards, through: :memberships

current_user.owned_boards — boards this user created.
current_user.boards — all boards this user is a member of (the full set, used in Module 11).

The Board model needs a direct belongs_to — not optional: true, because every board must have an owner:

1
2
# app/models/board.rb
belongs_to :user

Your database should already have a NOT NULL constraint on boards.user_id. If you previously added optional: true to work around an error, remove it now — the constraint is correct and optional: true was masking the real problem, which was the missing owned_boards association.

In the boards controller, use owned_boards when creating:

1
@board = current_user.owned_boards.build(board_params)

This sets user_id correctly through the direct association. Using current_user.boards would attempt to build through the memberships join table and leave user_id as nil — which is what causes the NOT NULL constraint failure.

The current_user method itself is stubbed in ApplicationController until Module 11:

Stubbing current_user

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include ToastHelper

  private

  def current_user
    @current_user ||= User.first
  end
  helper_method :current_user
end

current_user is stubbed for now — it returns User.first until Module 11 wires up real authentication. The stub lives in ApplicationController so it’s available everywhere the real implementation will be.

What current_user.boards returns

There’s an important distinction between the two board associations on User that affects what appears in the boards index:

current_user.owned_boards — boards where boards.user_id = current_user.id. This is the direct ownership association.

current_user.boards — boards the user is a member of, via the memberships join table. A board only appears here if a Membership record exists linking the user to that board.

When you create a board via current_user.owned_boards.build, the board gets user_id set correctly — but no Membership record is created. So current_user.boards won’t include it.

This means the boards index must use owned_boards for now:

1
2
3
def index
  render Views::Boards::Index.new(boards: current_user.owned_boards)
end

In Module 11, when membership is properly wired, board creation will also create a membership record for the owner. At that point the index can show all boards the user has access to — both owned and invited. Until then, owned_boards is correct and honest.

This is a good example of a design decision that looks minor early on but has real consequences. The two associations serve different purposes: owned_boards for ownership and admin rights, boards for access. Both will be used in Module 11.

Boards 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# app/controllers/boards_controller.rb
class BoardsController < ApplicationController
  def index
    render Views::Boards::Index.new(boards: current_user.boards)
  end

  def show
    render Views::Boards::Show.new(board: board)
  end

  def new
    render Views::Boards::New.new(board: Board.new)
  end

  def create
    @board = current_user.boards.build(board_params)
    if @board.save
      redirect_to board_path(@board), status: :see_other
    else
      render Views::Boards::New.new(board: @board),
             status: :unprocessable_entity
    end
  end

  def edit
    render Views::Boards::Edit.new(board: board)
  end

  def update
    if board.update(board_params)
      redirect_to board_path(board), status: :see_other
    else
      render Views::Boards::Edit.new(board: board),
             status: :unprocessable_entity
    end
  end

  def destroy
    board.destroy
    redirect_to boards_path, status: :see_other
  end

  private

  def board
    @board ||= Board.find(params[:id])
  end

  def board_params
    params.expect(board: Views::Boards::BoardForm.permitted)
  end
end

Columns 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# app/controllers/columns_controller.rb
class ColumnsController < ApplicationController
  def new
    @board  = Board.find(params[:board_id])
    @column = @board.columns.build
    render Views::Columns::New.new(column: @column, board: @board)
  end

  def create
    @board  = Board.find(params[:board_id])
    @column = @board.columns.build(column_params)
    if @column.save
      redirect_to board_path(@board), status: :see_other
    else
      render Views::Columns::New.new(column: @column, board: @board),
             status: :unprocessable_entity
    end
  end

  def edit
    render Views::Columns::Edit.new(column: column)
  end

  def update
    if column.update(column_params)
      redirect_to board_path(column.board), status: :see_other
    else
      render Views::Columns::Edit.new(column: column),
             status: :unprocessable_entity
    end
  end

  def destroy
    column.destroy
    redirect_to board_path(column.board), status: :see_other
  end

  private

  def column
    @column ||= Column.find(params[:id])
  end

  def column_params
    params.expect(column: Views::Columns::ColumnForm.permitted)
  end
end

Cards 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
  def new
    @column = Column.find(params[:column_id])
    @card   = @column.cards.build
    render Views::Cards::New.new(card: @card, column: @column)
  end

  def create
    @column = Column.find(params[:column_id])
    @card   = @column.cards.build(card_params)
    if @card.save
      redirect_to board_path(@column.board), status: :see_other
    else
      render Views::Cards::New.new(card: @card, column: @column),
             status: :unprocessable_entity
    end
  end

  def edit
    render Views::Cards::Edit.new(card: card)
  end

  def update
    if card.update(card_params)
      redirect_to board_path(card.column.board), status: :see_other
    else
      render Views::Cards::Edit.new(card: card),
             status: :unprocessable_entity
    end
  end

  def destroy
    card.destroy
    redirect_to board_path(card.column.board), status: :see_other
  end

  private

  def card
    @card ||= Card.find(params[:id])
  end

  def card_params
    params.expect(card: Views::Cards::CardForm.permitted)
  end
end

Form components

 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
# app/views/columns/column_form.rb
class Views::Columns::ColumnForm < Components::Base
  PERMITTED = [:name].freeze
  def self.permitted = PERMITTED

  prop :column, Column
  prop :board,  _Nilable(Board), default: -> { nil }

  def view_template
    form_with(
      model:  @column.persisted? ? @column : [@board, @column],
      class:  "space-y-4"
    ) do |f|
      FormField(
        field:    :name,
        form:     f,
        label:    "Column name",
        required: true
      ) do
        TextInput(field: :name, form: f, value: @column.name,
                  placeholder: "e.g. To Do")
      end

      div(class: "flex gap-3") do
        Button(label: @column.persisted? ? "Save" : "Add column",
               type: "submit")
        Button(label: "Cancel", variant: :outline,
               href: board_path(@column.persisted? ? @column.board_id : @board.id))
      end
    end
  end
end
 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
# app/views/cards/card_form.rb
class Views::Cards::CardForm < Components::Base
  PERMITTED = [:title, :description].freeze
  def self.permitted = PERMITTED

  prop :card,   Card
  prop :column, _Nilable(Column), default: -> { nil }

  def view_template
    form_with(
      model: @card.persisted? ? @card : [@column, @card],
      class: "space-y-4"
    ) do |f|
      FormField(
        field:    :title,
        form:     f,
        label:    "Title",
        required: true,
        error:    @card.errors[:title].first
      ) do
        TextInput(field: :title, form: f, value: @card.title,
                  placeholder: "Card title",
                  error: @card.errors[:title].any?)
      end
 
      div(class: "flex gap-3") do
        Button(label: @card.persisted? ? "Save" : "Add card",
               type: "submit")
        Button(label: "Cancel", variant: :outline,
               href: board_path(@card.persisted? ? @card.column.board_id
                                                 : @column.board_id))
      end
    end
  end
end

Views for columns and cards

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/views/columns/new.rb
class Views::Columns::New < Views::Base
  def page_title = "Add column"

  def initialize(column:, board:)
    @column = column
    @board  = board
  end

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

    Panel(title: "Add column") do
      ColumnForm(column: @column, board: @board)
    end
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# app/views/columns/edit.rb
class Views::Columns::Edit < Views::Base
  def page_title = "Edit column"

  def initialize(column:)
    @column = column
  end

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

    Panel(title: "Edit column") do
      Views::Columns::ColumnForm(column: @column)
    end
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/views/cards/new.rb
class Views::Cards::New < Views::Base
  def page_title = "Add card"

  def initialize(card:, column:)
    @card   = card
    @column = column
  end

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

    Panel(title: "Add card") do
      Views::Cards::CardForm(card: @card, column: @column)
    end
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# app/views/cards/edit.rb
class Views::Cards::Edit < Views::Base
  def page_title = "Edit card"

  def initialize(card:)
    @card = card
  end

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

    Panel(title: "Edit card") do
      Views::Cards::CardForm(card: @card)
    end
  end
end

What we have so far

With morphing enabled and these views in place, KanbanFlow already handles the full CRUD lifecycle for boards, columns, and cards. Creating a card redirects back to the board — Turbo morphs the new card into the column. Deleting a card redirects back — Turbo morphs it away. No Frames, no Streams, no explicit wiring.

The board view navigates to separate pages for creating and editing cards and columns. That’s intentional for now — in Lesson 6 we’ll bring the creation forms inline using Turbo Frames, which is one of the two things Frames still do well.