Skip to content

Lesson 5 — BoardForm and self-permitting forms

BoardForm

In standard Rails, scaffolding generates a _form partial shared between new and edit views. The partial handles both creating and updating a record — form_with infers the correct action and HTTP method from whether the model is persisted.

We follow the same pattern in Phlex. The minimal Views::Boards::New we built in Lesson 1 used inline builder methods directly. We now replace it with a dedicated BoardForm view fragment — the Phlex equivalent of the Rails _form partial — shared between a proper New view and a new Edit view.

The result is the same structure Rails developers already know:

  • _form partial → Views::Boards::BoardForm
  • new.html.erb → Views::Boards::New
  • edit.html.erb → Views::Boards::Edit

With one meaningful improvement: BoardForm declares its own permitted parameters via the PERMITTED constant, so the controller never needs to maintain a separate list.

BoardForm is an app-specific view fragment — not a generic form abstraction. It knows about Board, uses our primitives, and handles its own error display. It lives under Views:: rather than Components:: because it is app-specific and not a portable library component.

 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
# app/views/boards/board_form.rb
class Views::Boards::BoardForm < Views::Base
  PERMITTED = [:name].freeze
  def self.permitted = PERMITTED

  prop :board, Board

  def view_template
    form_with(model: @board, class: "space-y-4") do |f|
      TextInput(
        field:       :name,
        label:       "Board name",
        value:       @board.name,
        placeholder: "e.g. Marketing Q3",
        error:       @board.errors[:name].first,
        required:    true
      )

      div(class: "flex items-center gap-3 pt-2") do
        Button(
          label: @board.persisted? ? "Update board" : "Create board",
          type:  "submit"
        )
        Button(label: "Cancel", variant: :ghost, href: boards_path)
      end
    end
  end
end

Self-permitting forms

BoardForm knows which fields it renders. The PERMITTED constant pattern means the controller never needs to maintain a separate list:

1
2
3
4
# app/controllers/boards_controller.rb
def board_params
  params.expect(board: Views::Boards::BoardForm.permitted)
end

params.expect vs params.require — Rails 8 introduces params.expect as the preferred way to handle strong parameters. It’s safer than the .require.permit chain because it handles malformed params gracefully with a 400 response rather than a 500 error. See the Rails docs for the full details.

Add a field to BoardForm and it is automatically permitted. Remove a field and it is automatically removed. The controller never changes.

PERMITTED is a frozen array for three reasons:

  • Load time — evaluated once, not on every request
  • Testableassert_includes Views::Boards::BoardForm.permitted, :name
  • ComposableSubForm.permitted = ParentForm.permitted + [:extra]

For nested attributes the pattern extends naturally:

1
2
3
4
PERMITTED = [
  :name,
  columns_attributes: [[:id, :name, :position, :_destroy]]
].freeze

Views::Boards::New

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

  def initialize(board:)
    @board = board
  end

  def view_template
    Breadcrumb() do |b|
      b.item "Boards", url: boards_path
      b.item "New Board"
    end

    div(class: "max-w-lg mt-6") do
      h1(class: "text-2xl font-bold text-gray-900 mb-6") { "New Board" }
      render Views::Boards::BoardForm.new(board: @board)
    end
  end
end

Views::Boards::Edit

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/views/boards/edit.rb
class Views::Boards::Edit < Views::Base
  def page_title = "Edit #{@board.name}"

  def initialize(board:)
    @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 "Edit"
    end

    div(class: "max-w-lg mt-6") do
      h1(class: "text-2xl font-bold text-gray-900 mb-6") { "Edit Board" }
      render Views::Boards::BoardForm.new(board: @board)
    end
  end
end

BoardForm works identically for new and edit — form_with detects model.persisted? and sets the correct method and URL. The submit button label changes via @board.persisted?.