Skip to content

Lesson 3 — Expanding Table

The Table component from Module 3 handles basic column definitions well. KanbanFlow needs more — row actions, and proper empty state handling using the EmptyState component we just built in Lesson 2.

What we’re adding

Two additions to the existing Table component:

  1. Empty state — when rows is empty, render EmptyState rather than an empty table or a plain paragraph
  2. Row actions — an optional actions block that renders per-row action links in a dedicated column

The updated 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
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
# app/components/table.rb
class Components::Table < Components::Base
  prop :rows,    _Array(_Any)
  prop :caption, _Nilable(String), default: -> { nil }

  def after_initialize
    @columns      = []
    @action_block = nil
  end

  def view_template(&)
    vanish(&)

    if @rows.empty?
      render_empty_state
    else
      render_table
    end
  end

  def column(header, &content)
    @columns << { header:, content: }
    nil
  end

  def actions(&block)
    @action_block = block
    nil
  end

  private

  def render_empty_state
    EmptyState(
      title:   "No records found",
      message: "There's nothing to display yet."
    )
  end

  def render_table
    div(class: "overflow-x-auto rounded-lg border border-gray-200") do
      table(class: "w-full text-sm text-left") do
        caption(class: "px-4 py-2 text-sm text-gray-500 text-left") do
          @caption
        end if @caption

        thead(class: "bg-gray-50 text-xs text-gray-700 uppercase tracking-wider") do
          tr do
            @columns.each do |col|
              th(class: "px-4 py-3 font-medium") { col[:header] }
            end
            th(class: "px-4 py-3 font-medium") { "Actions" } if @action_block
          end
        end

        tbody(class: "divide-y divide-gray-100") do
          @rows.each do |row|
            tr(class: "hover:bg-gray-50 transition-colors") do
              @columns.each do |col|
                td(class: "px-4 py-3 text-gray-700") do
                  plain col[:content].call(row).to_s
                end
              end
              if @action_block
                td(class: "px-4 py-3") do
                  instance_exec(row, &@action_block)
                end
              end
            end
          end
        end
      end
    end
  end
end

Why EmptyState rather than a plain paragraph

We built EmptyState in Lesson 2 specifically for this use case — a collection with no records. Using it here rather than a plain p tag keeps empty state handling consistent throughout the app. Every empty collection looks and behaves the same way.

The title and message props on render_empty_state are hardcoded defaults. If you need a custom empty message for a specific table, pass it via a prop — but for most cases the defaults are fine.

Why instance_exec for actions

The actions block executes inside each row:

1
2
3
4
5
if @action_block
  td(class: "px-4 py-3") do
    instance_exec(row, &@action_block)
  end
end

instance_exec(row, &@action_block) rather than @action_block.call(row) is deliberate. instance_exec runs the block in the context of the Table instance — which means Phlex tag methods (a, span, button etc.) are available inside the block:

1
2
3
4
5
6
7
Table(rows: @boards) do |t|
  t.column("Name") { |board| board.name }
  t.actions do |board|
    a(href: board_path(board), class: "text-blue-600 hover:underline mr-3") { "View" }
    a(href: edit_board_path(board), class: "text-gray-600 hover:underline")  { "Edit" }
  end
end

If we used @action_block.call(row) instead, the block would run in the caller’s context — Phlex tag methods wouldn’t be available, and a(href: ...) would raise NoMethodError.

This is the same mechanism Phlex uses internally for yield(self) — the block runs in the component’s rendering context so all output methods are available.

Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Table(rows: @boards) do |t|
  t.column("Name")    { |board| board.name }
  t.column("Columns") { |board| board.columns.count.to_s }
  t.actions do |board|
    a(href: board_path(board),
      class: "text-blue-600 hover:underline mr-3") { "View" }
    a(href: edit_board_path(board),
      class: "text-gray-600 hover:underline") { "Edit" }
  end
end

Empty rows — pass an empty array and EmptyState renders automatically:

1
2
3
4
Table(rows: []) do |t|
  t.column("Name") { |row| row[:name] }
end
# => renders EmptyState with "No records found"

Note on styling: Table currently uses raw Tailwind palette values (bg-gray-50, text-gray-700 etc.). In Module 7 these are replaced with semantic tokens — bg-surface-alt, text-text etc. — as part of the full Phlex::UI restyling. The structure and behaviour are unchanged.

Lookbook preview

 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
# test/components/previews/table_preview.rb
PREVIEW_PEOPLE = [
  { name: "Alice", role: "Admin"  },
  { name: "Bob",   role: "Member" },
  { name: "Carol", role: "Member" },
].freeze

class TablePreview < Lookbook::Preview
  def default
    render Components::Table.new(rows: PREVIEW_PEOPLE) { |t|
      t.column("Name") { |row| row[:name] }
      t.column("Role") { |row| row[:role] }
    }
  end

  def with_caption
    render Components::Table.new(
      rows:    PREVIEW_PEOPLE,
      caption: "Team members"
    ) { |t|
      t.column("Name") { |row| row[:name] }
      t.column("Role") { |row| row[:role] }
    }
  end

  def with_actions
    render Components::Table.new(rows: PREVIEW_PEOPLE) { |t|
      t.column("Name") { |row| row[:name] }
      t.column("Role") { |row| row[:role] }
      t.actions do |row|
        a(href: "#", class: "text-blue-600 hover:underline mr-3") { "Edit" }
        a(href: "#", class: "text-red-600 hover:underline")       { "Delete" }
      end
    }
  end

  def empty
    render Components::Table.new(rows: []) { |t|
      t.column("Name") { |row| row[:name] }
      t.column("Role") { |row| row[:role] }
    }
  end
end

The empty scenario is the most important preview to check — confirm that EmptyState renders correctly rather than an empty table structure.