Skip to content

Lesson 5 — Inline add and edit forms

The approach

Creating and editing cards and columns currently navigates to a separate page. That works, but it breaks board context — the user loses sight of their board to fill in a one-field form.

The fix is simple: render the forms inline in the board, hidden by default, and toggle them with Stimulus. No Turbo Frames, no streams, no navigation. The form is already in the DOM — Stimulus shows and hides it. When the form submits successfully the controller redirects back to the board and morph updates it. Validation errors are caught client-side before the form ever submits — no server round-trip needed for a single required field.

Form components

Each entity has one form component that handles both adding and editing. The form model and button label are derived from whether the record is persisted:

 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
# app/views/cards/card_form.rb
class Views::Cards::CardForm < Components::Base
  prop :card,   ::Card
  prop :column, _Nilable(::Column), default: -> { nil }

  def view_template
    form_with(
      model: @card.persisted? ? @card : [@column, @card],
      class: "space-y-2",
      data:  { action: "submit->card-form#submit" }
    ) do |f|
      f.text_field :title,
        autofocus:   true,
        placeholder: "Card title",
        class:       "w-full rounded-md border border-border px-3 py-2 " \
                     "text-sm bg-surface text-text " \
                     "placeholder:text-text-subtle " \
                     "focus:outline-none focus:ring-2 focus:ring-primary",
        data: { card_form_target: "input" }

      p(
        hidden: true,
        class:  "text-danger text-xs",
        data:   { card_form_target: "error" }
      )

      div(class: "flex gap-2 mt-1") do
        Button(label: @card.persisted? ? "Save" : "Add card",
               type: "submit", size: :sm)
        button(
          type:  "button",
          class: "text-sm text-text-muted hover:text-text px-2 py-1",
          data:  { action: "click->card-form#hideForm" }
        ) { "Cancel" }
      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
36
37
38
# app/views/columns/column_form.rb
class Views::Columns::ColumnForm < Components::Base
  prop :column, ::Column
  prop :board,  _Nilable(::Board), default: -> { nil }

  def view_template
    form_with(
      model: @column.persisted? ? @column : [@board, @column],
      class: "space-y-2",
      data:  { action: "submit->column-form#submit" }
    ) do |f|
      f.text_field :name,
        autofocus:   true,
        placeholder: "Column name",
        class:       "w-full rounded-md border border-border px-3 py-2 " \
                     "text-sm bg-surface text-text " \
                     "placeholder:text-text-subtle " \
                     "focus:outline-none focus:ring-2 focus:ring-primary",
        data: { column_form_target: "input" }

      p(
        hidden: true,
        class:  "text-danger text-xs",
        data:   { column_form_target: "error" }
      )

      div(class: "flex gap-2 mt-1") do
        Button(label: @column.persisted? ? "Save" : "Add column",
               type: "submit", size: :sm)
        button(
          type:  "button",
          class: "text-sm text-text-muted hover:text-text px-2 py-1",
          data:  { action: "click->column-form#hideForm" }
        ) { "Cancel" }
      end
    end
  end
end

The permitted class method and PERMITTED constant stay on each form for the controllers to use:

1
2
3
4
5
6
7
# app/views/cards/card_form.rb
PERMITTED = [:title].freeze
def self.permitted = PERMITTED

# app/views/columns/column_form.rb
PERMITTED = [:name].freeze
def self.permitted = PERMITTED

Stimulus controllers

Two explicit controllers — one per entity. They are structurally identical, which is intentional. They serve separate components with separate concerns. A shared abstraction would add coupling without meaningful benefit for two controllers this small.

 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
// app/javascript/controllers/card_form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["link", "form", "input", "error"]

  showForm() {
    this.linkTarget.hidden = true
    this.formTarget.hidden = false
    this.inputTarget.focus()
  }

  hideForm() {
    this.formTarget.querySelector("form")?.reset()
    this.linkTarget.hidden = false
    this.formTarget.hidden = true
    this.clearError()
  }

  submit(event) {
    if (this.inputTarget.value.trim() === "") {
      event.preventDefault()
      this.showError("Title can't be blank")
    }
  }

  showError(message) {
    this.errorTarget.textContent = message
    this.errorTarget.hidden = false
  }

  clearError() {
    this.errorTarget.textContent = ""
    this.errorTarget.hidden = true
  }
}
 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
// app/javascript/controllers/column_form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["display", "form", "input", "error"]

  showForm() {
    this.displayTarget.hidden = true
    this.formTarget.hidden = false
    this.inputTarget.focus()
  }

  hideForm() {
    this.formTarget.querySelector("form")?.reset()
    this.displayTarget.hidden = false
    this.formTarget.hidden = true
    this.clearError()
  }

  submit(event) {
    if (this.inputTarget.value.trim() === "") {
      event.preventDefault()
      this.showError("Name can't be blank")
    }
  }

  showError(message) {
    this.errorTarget.textContent = message
    this.errorTarget.hidden = false
  }

  clearError() {
    this.errorTarget.textContent = ""
    this.errorTarget.hidden = true
  }
}

Note that card_form_controller uses link as its display target (the “+ Add card” button) while column_form_controller uses display (the column header). Different target names because they’re toggling different kinds of elements.

form.reset() in hideForm restores inputs to their original page-load values. This is important for the edit case — if you clear the title, see the validation error, then cancel, the input must restore to the original title rather than staying empty.

Client-side validation catches the blank field before submission. The server-side presence: true validation remains as a safety net but the 422 error path is effectively never reached in normal use.

KanbanCard

The card component now renders both the display state and the edit form, toggled by card-form 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
53
54
55
56
57
58
59
60
61
62
# 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 hover:shadow-md transition-shadow",
      data:  {
        controller: "card-form",
        card_id:    @card.id
      }
    ) do
      render_display
      render_edit_form
    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 }
      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

  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
end

The drag handle (cursor-grab) is on the display div, not the card wrapper. This means dragging only works when the display is visible — you can’t accidentally drag a card while its edit form is showing.

KanbanColumn

The column renders its header with an inline edit form toggle, its card list, the add card toggle, and delete button:

  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
118
119
120
121
# 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: "mb-3",
      data:  { controller: "column-form" }
    ) do
      render_column_display
      render_column_edit_form
    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)
      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

  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 { |card| KanbanCard(card: card) }
    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"
        }
      ) do
        plain "+ Add card"
      end

      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
end

Note that render_add_card has its own data-controller="card-form" wrapper — separate from the card edit controllers on each KanbanCard. Each controller instance is scoped to its own element, so there’s no conflict between the “Add card” toggle and the individual card edit toggles.

Controllers

With inline forms on the board, the cards and columns controllers simplify. There are no longer separate New and Edit views for cards and columns — everything happens on the board:

 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
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
  def create
    @column = Column.find(params[:column_id])
    @card   = @column.cards.build(card_params)
    @card.save
    redirect_to board_path(@column.board), status: :see_other
  end

  def update
    card.update(card_params)
    redirect_to board_path(card.column.board), status: :see_other
  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
 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
# app/controllers/columns_controller.rb
class ColumnsController < ApplicationController
  def create
    @board  = Board.find(params[:board_id])
    @column = @board.columns.build(column_params)
    @column.save
    redirect_to board_path(@board), status: :see_other
  end

  def update
    column.update(column_params)
    redirect_to board_path(column.board), status: :see_other
  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

The controllers always redirect back to the board. Client-side validation means the blank field case never reaches the server in normal use. The save and update calls are not guarded with if — the server-side validation is a safety net, not a code path we design UI around.

Icons needed

The edit buttons use :pencil. Add it to Components::Icon::ICONS:

1
2
3
4
5
6
7
8
9
pencil: {
  paths: [
    "M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 " \
    "16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-" \
    "1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 " \
    "0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 " \
    "015.25 6H10"
  ]
},

Updating Views::Boards::Show

The “+ Add column” link previously navigated to new_board_column_path(@board). Now that the new action has been removed from ColumnsController, that link would raise a routing error. The add column interaction moves inline — the same toggle pattern as “+ Add card”. Update render_add_column in Views::Boards::Show to replace the link with an inline toggle:

def render_add_column
  div(
    class: "w-72 shrink-0",
    data:  { controller: "column-form" }
  ) do
    button(
      type:  "button",
      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 w-full " \
             "hover:border-border-strong",
      data:  {
        column_form_target: "display",
        action:             "click->column-form#showForm"
      }
    ) do
      plain "+ Add column"
    end

    div(
      hidden: true,
      class:  "bg-surface-alt/50 rounded-lg p-3 border-2 border-dashed border-border",
      data:   { column_form_target: "form" }
    ) do
      render Views::Columns::ColumnForm.new(
        column: @board.columns.build,
        board:  @board
      )
    end
  end
end

Files to remove

The build pass produced several intermediate form files that the final design supersedes. Delete:

app/views/cards/inline_form.rb
app/views/cards/edit_form.rb
app/views/cards/card_inline_form.rb
app/views/cards/new.rb
app/views/cards/edit.rb
app/views/columns/edit_form.rb
app/views/columns/column_inline_form.rb
app/views/columns/new.rb
app/views/columns/edit.rb

Also remove the new and edit actions from both controllers — they’re no longer needed since forms render inline on the board.

Routes

With new and edit actions removed, tighten the routes:

1
2
3
4
5
resources :boards do
  resources :columns, shallow: true, only: [:create, :update, :destroy] do
    resources :cards, shallow: true, only: [:create, :update, :destroy]
  end
end

What we now have

The board is fully self-contained. Every card and column interaction happens inline:

  • Add card — click “+ Add card”, type title, submit. Morph adds the card to the column
  • Edit card — click the pencil, update title, submit. Morph updates the card in place
  • Delete card — click ×, confirm. Morph removes the card
  • Edit column title — click the pencil on the column header, update, submit. Morph updates the header
  • Delete column — click × on the column header, confirm with card count. Morph removes the column and all its cards
  • Add column — click “+ Add column”, type name, submit. Morph adds the column to the board
  • Reorder cards — drag within or between columns. Position persisted via PATCH
  • Reorder columns — drag by the column header. Position persisted via PATCH

No page navigations. No Turbo Frames. No Streams. Morph and Stimulus doing exactly what they’re designed to do.