Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Component-Driven UI with ViewComponent Gem

Component-Driven UI with ViewComponent Gem

Radoslav Stankov

October 05, 2023
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. Component-Driven UI
    with
    ViewComponent Gem
    Radoslav Stankov

    View Slide

  2. !

    View Slide

  3. Radoslav Stankov
    @rstankov rstankov.com

    View Slide

  4. View Slide

  5. View Slide

  6. Product Hunt Architecture

    View Slide

  7. View Slide

  8. " Side project

    View Slide

  9. Angry Building Architecture #
    $ Ruby on Rails
    % No JavaScript (except rails-ujs)
    & No CSS (except Tailwind)
    ' Extensive e2e tests
    ( Focus on domain

    View Slide

  10. I ❤

    View Slide

  11. I ❤

    View Slide

  12. View Slide

  13. View Slide

  14. View Slide

  15. View Slide

  16. View Slide

  17. View Slide

  18. Title
    Content

    View Slide

  19. <%= render FieldsetComponent.new(fieldset, title: :bank_account) do %>
    <%= form.input :bank_account_bank %>
    <%= form.input :bank_account_iban %>
    <%= form.input :bank_account_bic %>
    <% end %>

    View Slide

  20. class FieldsetComponent < ViewComponent::Base
    attr_reader :title
    def initialize(title:)
    @title = title
    end
    end
    app/components/fieldset_component.rb

    View Slide



  21. <%= title %>


    <%= content %>


    app/components/fieldset_component.html.erb

    View Slide



  22. <%= title %>


    <%= content %>


    app/components/fieldset_component.html.erb
    Where content
    comes from?
    *

    View Slide

  23. View Slide

  24. View Slide

  25. View Slide

  26. class FieldsetComponentPreview < ViewComponent::Preview
    # @param title
    # @param text
    def default(title: 'title', content: 'content')
    render(FieldsetComponent.new(title: title)) do
    content
    end
    end
    end
    spec/components/previews/fieldset_component_preview.rb

    View Slide

  27. View Slide

  28. View Slide

  29. What goes to helper?
    What goes to partial?
    What goes to component?

    View Slide

  30. + ViewComponent Checklist
    I use a ViewComponent when:
    , considering extracting a partial that will be used in 2+ controllers
    - considering extracting a view helper that generates HTML
    . have complicated deep nested if-elsif-else
    / copy a lot of logic around
    0 have to connect with JavaScript
    I don't use ViewComponent when:
    1 a partial is only used in one controller (example: _form.html.erb)
    2 view helper, which is a simple pure function (example: format_money)
    3 there is a lot of HTML on one single page - leave it there 4
    (not so simple checklist)

    View Slide

  31. 1) Helpers 2) UI Components 3) Domain Components

    View Slide

  32. format_money MoneyComponent ProductPriceComponent

    View Slide

  33. View Slide

  34. <%= component :field_set, title: 'title' %>
    <%= render FieldsetComponent(title: 'title') %>
    5 component helper

    View Slide

  35. module ApplicationHelper
    def component(name, *args, **kwargs, &)
    render("#{name}_component".classify.constantize.new(*args, **kwargs), &)
    end
    end
    5 component helper

    View Slide

  36. View Slide

  37. View Slide

  38. View Slide

  39. Navigation
    Page Header
    Filter Form
    Stats
    Table

    View Slide

  40. NavigationComponent
    PageHeaderComponent
    FilterFormComponent
    StatsComponent
    TableComponent

    View Slide

  41. <%= component :page_header do |header| %>
    <% header.breadcrumbs(@search.building) %>
    <% header.actions do %>
    <%= component :action_menu do |menu| %>
    <% menu.action :new_transaction, new_building_transaction_path(@building) %>
    <% menu.action :export_zip, building_transaction_path(@building, format: :zip, **@search.params) %>
    <% menu.action :export_excel, building_transaction_path(@building, format: :xls, **@search.params) %>
    <% menu.action :export_print, building_transaction_path(@building, variant: :print, **@search.params) %>
    <% end %>
    <% end %>
    <% end %>
    <%= component :filter_form, params: params do |form| %>
    <% form.search :query %>
    <% form.select :user_id, placeholder: :cashier, options: t_options(@search.user_options), classes: 'w-36' %>
    <% form.select :source, placeholder: :source, options: @search.source_options, classes: 'w-56' %>
    <% form.date_range :date %>
    <% form.select :kind, placeholder: :kind, options: @search.kind_options %>
    <% end %>
    <%= component :stats do |c| %>
    <% c.number :balance,
    amount: format_money(@search.balance),
    color: @search.balance.positive? ? :green : :red %>
    <% c.number title: :cash_reserve,
    amount: format_money(@search.cash_reserve_amount),
    color: :gray %>
    <% c.number title: :wallet_amount,
    amount: format_money(@search.wallet_amount),
    color: :gray %>
    <% c.number title: :transaction_income,
    amount: format_money(@search.income_amount),
    color: :green %>
    <% c.number title: :transaction_expense,
    amount: format_money(@search.expense_amount),
    color: :red %>
    <% c.number title: :total_count,
    amount: @search.total_count,
    color: :gray %>
    <% end %>
    <%= component :table, @search.results do |table| %>
    <% table.record :name, :itself %>
    <% table.record :apartment %>
    <% table.number :document do |record| %>
    <% record.documents.each do |document| %>
    <%= link_to document.display_number, document_path(document), class: 'link' %>
    <% end %>
    <% end %>
    <% table.record :cashier, :user %>
    <% table.date :date %>
    <% table.money :cash_reserve_amount %>
    <% table.money :wallet_amount %>
    <% table.money :total_amount %>
    <% table.column :kind do |record| %>
    <%= component :transaction_kind_badge, record %>
    <% end %>
    <% table.actions do |record| %>
    <%= button_details transaction_path(record) %>
    <% end %>
    <% end %>

    View Slide

  42. <%= component :page_header do |header| %>
    <% header.breadcrumbs(@search.building) %>
    <% header.actions do %>
    <%= component :action_menu do |menu| %>
    <% menu.action :new_transaction, new_building_transaction_path(@building) %>
    <% menu.action :export_zip, building_transaction_path(@building, format: :zip, **@search.params) %>
    <% menu.action :export_excel, building_transaction_path(@building, format: :xls, **@search.params) %>
    <% menu.action :export_print, building_transaction_path(@building, variant: :print, **@search.params) %>
    <% end %>
    <% end %>
    <% end %>
    <%= component :filter_form, params: params do |form| %>
    <% form.search :query %>
    <% form.select :user_id, placeholder: :cashier, options: t_options(@search.user_options), classes: 'w-36' %>
    <% form.select :source, placeholder: :source, options: @search.source_options, classes: 'w-56' %>
    <% form.date_range :date %>
    <% form.select :kind, placeholder: :kind, options: @search.kind_options %>
    <% end %>
    <%= component :stats do |c| %>
    <% c.number :balance,
    amount: format_money(@search.balance),
    color: @search.balance.positive? ? :green : :red %>
    <% c.number title: :cash_reserve,
    amount: format_money(@search.cash_reserve_amount),
    color: :gray %>
    <% c.number title: :wallet_amount,
    amount: format_money(@search.wallet_amount),
    color: :gray %>
    <% c.number title: :transaction_income,
    amount: format_money(@search.income_amount),
    color: :green %>
    <% c.number title: :transaction_expense,
    amount: format_money(@search.expense_amount),
    color: :red %>
    <% c.number title: :total_count,
    amount: @search.total_count,
    color: :gray %>
    <% end %>
    <%= component :table, @search.results do |table| %>
    <% table.record :name, :itself %>
    <% table.record :apartment %>
    <% table.number :document do |record| %>
    <% record.documents.each do |document| %>
    <%= link_to document.display_number, document_path(document), class: 'link' %>
    <% end %>
    <% end %>
    <% table.record :cashier, :user %>
    <% table.date :date %>
    <% table.money :cash_reserve_amount %>
    <% table.money :wallet_amount %>
    <% table.money :total_amount %>
    <% table.column :kind do |record| %>
    <%= component :transaction_kind_badge, record %>
    <% end %>
    <% table.actions do |record| %>
    <%= button_details transaction_path(record) %>
    <% end %>
    <% end %>
    ... and the code fits on a slide 6

    View Slide

  43. View Slide

  44. View Slide

  45. View Slide

  46. <%= component :page_header do |header| %>
    <% header.breadcrumbs(@search.building) %>
    <% header.actions do %>
    <%= component :action_menu do |menu| %>
    <% menu.action :new_transaction, new_building_transaction_path(@building) %>
    <% menu.action :export_zip, building_transaction_path(@building, format: :zip, **@search.params) %>
    <% menu.action :export_excel, building_transaction_path(@building, format: :xls, **@search.params) %>
    <% menu.action :export_print, building_transaction_path(@building, variant: :print, **@search.params) %>
    <% end %>
    <% end %>
    <% end %>
    <%= component :filter_form, params: params do |form| %>
    <% form.search :query %>
    <% form.select :user_id, placeholder: :cashier, options: t_options(@search.user_options), classes: 'w-36' %>
    <% form.select :source, placeholder: :source, options: @search.source_options, classes: 'w-56' %>
    <% form.date_range :date %>
    <% form.select :kind, placeholder: :kind, options: @search.kind_options %>
    <% end %>
    <%= component :stats do |c| %>
    <% c.number :balance,
    amount: format_money(@search.balance),
    color: @search.balance.positive? ? :green : :red %>
    <% c.number title: :cash_reserve,
    amount: format_money(@search.cash_reserve_amount),
    color: :gray %>
    <% c.number title: :wallet_amount,
    amount: format_money(@search.wallet_amount),
    color: :gray %>
    <% c.number title: :transaction_income,
    amount: format_money(@search.income_amount),
    color: :green %>
    <% c.number title: :transaction_expense,
    amount: format_money(@search.expense_amount),
    color: :red %>
    <% c.number title: :total_count,
    amount: @search.total_count,
    color: :gray %>
    <% end %>

    View Slide

  47. <% form.select :kind, placeholder: :kind, options: @search.kind_options %>
    <% end %>
    <%= component :stats do |c| %>
    <% c.number :balance,
    amount: format_money(@search.balance),
    color: @search.balance.positive? ? :green : :red %>
    <% c.number title: :cash_reserve,
    amount: format_money(@search.cash_reserve_amount),
    color: :gray %>
    <% c.number title: :wallet_amount,
    amount: format_money(@search.wallet_amount),
    color: :gray %>
    <% c.number title: :transaction_income,
    amount: format_money(@search.income_amount),
    color: :green %>
    <% c.number title: :transaction_expense,
    amount: format_money(@search.expense_amount),
    color: :red %>
    <% c.number title: :total_count,
    amount: @search.total_count,
    color: :gray %>
    <% end %>
    <%= component :table, @search.results do |table| %>
    <% table.record :name, :itself %>

    View Slide

  48. 7 Slots
    StatsNumber
    Stats
    StatsNumber StatsNumber StatsNumber ...

    View Slide

  49. 7 Slots
    class StatsComponent < ApplicationComponent
    renders_many :numbers, StatsNumberComponent
    alias number with_number
    end
    StatsNumber
    Stats
    StatsNumber StatsNumber StatsNumber ...

    View Slide

  50. 7 Slots
    class StatsComponent < ApplicationComponent
    renders_many :numbers, StatsNumberComponent
    alias number with_number
    end
    StatsNumber
    Stats
    8 use alias hide that you are using a slot and have nice API
    StatsNumber StatsNumber StatsNumber ...

    View Slide

  51. 7 Slots
    class StatsComponent < ApplicationComponent
    renders_many :numbers, StatsNumberComponent
    alias number with_number
    end
    StatsNumber
    Stats
    StatsNumber StatsNumber StatsNumber ...

    View Slide

  52. 8 Tip: Slots

    <% numbers.each do |_1| %>
    <%= _1 %>
    <% end %>

    7 Slots
    StatsNumber
    Stats
    StatsNumber StatsNumber StatsNumber ...

    View Slide

  53. class StatsNumberComponent < ApplicationComponent
    attr_reader :title, :amount, :from, :color, :link
    COLORS = {
    red: 'text-red-600',
    green: 'text-green-600',
    gray: 'text-gray-600',
    }.freeze
    def initialize(title:, amount:, from: nil, color: :gray, link: nil)
    @title = title
    @amount = amount
    @from = from
    @color = fetch_with_fallback(COLORS, color, COLORS[:gray])
    @link = link
    end
    end

    View Slide

  54. class StatsNumberComponent < ApplicationComponent
    attr_reader :title, :amount, :from, :color, :link
    COLORS = {
    red: 'text-red-600',
    green: 'text-green-600',
    gray: 'text-gray-600',
    }.freeze
    def initialize(title:, amount:, from: nil, color: :gray, link: nil)
    @title = title
    @amount = amount
    @from = from
    @color = fetch_with_fallback(COLORS, color, COLORS[:gray])
    @link = link
    end
    end
    class ApplicationComponent < ViewComponent::Base
    private
    def fetch_with_fallback(hash, key, fallback)
    hash.fetch(key) do
    ErrorReporting.capture_exception(%(key not found: "#{key}"))
    fallback
    end
    end
    # ...
    end

    View Slide

  55. View Slide

  56. View Slide

  57. <% header.breadcrumbs(@search.building) %>
    <% header.actions do %>
    <%= component :action_menu do |menu| %>
    <% menu.action :new_transaction, new_building_transaction_path(@building) %>
    <% menu.action :export_zip, building_transaction_path(@building, format: :zip, **@searc
    <% menu.action :export_excel, building_transaction_path(@building, format: :xls, **@sea
    <% menu.action :export_print, building_transaction_path(@building, variant: :print, **@
    <% end %>
    <% end %>
    <% end %>
    <%= component :filter_form, params: params do |form| %>
    <% form.search :query %>
    <% form.select :user_id, placeholder: :cashier, options: t_options(@search.user_options) %>
    <% form.select :source, placeholder: :source, options: @search.source_options %>
    <% form.date_range :date %>
    <% form.select :kind, placeholder: :kind, options: @search.kind_options %>
    <% end %>
    <%= component :stats do |c| %>
    <% c.number :balance,
    amount: format_money(@search.balance),
    color: @search.balance.positive? ? :green : :red %>
    <% c.number title: :cash_reserve,
    amount: format_money(@search.cash_reserve_amount),
    color: :gray %>
    <% c.number title: :wallet_amount,
    amount: format_money(@search.wallet_amount),
    9 Builder pattern

    View Slide

  58. class FilterFormComponent < ApplicationComponent
    attr_reader :action, :inputs
    def initialize(action: nil, params: {})
    @params = params
    @action = action
    @inputs = []
    end
    def before_render
    content
    end
    def search(name, label: nil)
    def select(name, options:, label: nil, placeholder: nil, classes: nil)
    def text(name, label: nil, placeholder: nil, classes: nil)
    def date_range(name)
    end

    View Slide

  59. class FilterFormComponent < ApplicationComponent
    attr_reader :action, :inputs
    def initialize(action: nil, params: {})
    @params = params
    @action = action
    @inputs = []
    end
    def before_render
    content
    end
    def search(name, label: nil)
    def select(name, options:, label: nil, placeholder: nil, classes: nil)
    def text(name, label: nil, placeholder: nil, classes: nil)
    def date_range(name)
    end
    8 triggers the content block and thus builder method calls

    View Slide

  60. class FilterFormComponent < ApplicationComponent
    attr_reader :action, :inputs
    def initialize(action: nil, params: {})
    @params = params
    @action = action
    @inputs = []
    end
    def before_render
    content
    end
    def search(name, label: nil)
    def select(name, options:, label: nil, placeholder: nil, classes: nil)
    def text(name, label: nil, placeholder: nil, classes: nil)
    def date_range(name)
    end

    View Slide

  61. class FilterFormComponent < ApplicationComponent
    # ...
    def search(name, label: nil)
    @inputs << [label, render(SearchInputComponent.new(name, @params[name])
    end
    def select
    # ...
    end
    def text
    # ...
    end
    def date_range(name)
    # ...
    end
    end

    View Slide

  62. class FilterFormComponent < ApplicationComponent
    # ...
    def select(name, options:, label: nil, placeholder: nil, classes: nil)
    input = select_tag(
    name,
    options_for_select(options, @params[name]),
    class: "c-input #{classes}",
    )
    @inputs << [label, input]
    end
    def text
    # ...
    end
    def date_range(name)
    # ...
    end
    end

    View Slide

  63. class FilterFormComponent < ApplicationComponent
    # ...
    def select(name, options:, label: nil, placeholder: nil, classes: nil)
    input = select_tag(
    name,
    options_for_select(options, @params[name]),
    class: "c-input #{classes}",
    )
    @inputs << [label, input]
    end
    def text
    # ...
    end
    def date_range(name)
    # ...
    end
    end
    Why don't you
    use slots here?
    *

    View Slide

  64. class FilterFormComponent < ApplicationComponent
    # ...
    def text(name, label: nil, placeholder: nil, classes: nil)
    input = text_field_tag(
    name,
    @params[name],
    class: "c-input #{classes}",
    placeholder: t_label(placeholder),
    )
    @inputs << [label, input]
    end
    def date_range(name)
    # ...
    end
    end

    View Slide

  65. class FilterFormComponent < ApplicationComponent
    # ...
    def text(name, label: nil, placeholder: nil, classes: nil)
    input = text_field_tag(
    name,
    @params[name],
    class: "c-input #{classes}",
    placeholder: t_label(placeholder),
    )
    @inputs << [label, input]
    end
    def date_range(name)
    # ...
    end
    end
    def t_label(key)
    return '' if key.blank?
    return key if key.is_a?(String)
    t(key, default: :"label_#{key}")
    end

    View Slide

  66. class FilterFormComponent < ApplicationComponent
    # ...
    def date_range(name)
    gteq = date_field_tag("#{name}[gteq]", @params.dig(name, :gteq), class:
    lteq = date_field_tag("#{name}[lteq]", @params.dig(name, :lteq), class:
    @inputs << [:start_date, gteq]
    @inputs << [:end_date, lteq]
    end
    end

    View Slide

  67. <%= form_tag @action, method: :get, class: 'c-filter-form' do %>
    <% inputs.each do |(label, input)| %>

    <%= t_label(label) %>
    <%= input %>

    <% end %>
    <%= submit_tag t(:button_filter), name: nil, class: 'c-button' %>
    <% end %>

    View Slide

  68. View Slide

  69. View Slide

  70. <%= component :page_header do |header| %>
    <% header.breadcrumbs(@search.building) %>
    <% header.actions do %>
    <%= component :action_menu do |menu| %>
    <% menu.action :new_transaction, new_building_transaction_path(@buildin
    <% menu.action :export_zip, building_transaction_path(@building, format
    <% menu.action :export_excel, building_transaction_path(@building, form
    <% menu.action :export_print, building_transaction_path(@building, vari
    <% end %>
    <% end %>
    <% end %>

    View Slide

  71. class PageHeaderComponent < ApplicationComponent
    renders_many :breadcrumb_items, PageHeaderBreadcrumbComponent
    renders_one :actions_slot
    alias actions with_actions_slot
    def title(title)
    @title = helpers.t_display(title)
    helpers.content_for(:page_title, @title)
    end
    def breadcrumbs(*args)
    args.each { with_breadcrumb_item(_1) }
    end
    def before_render
    infer_title if @title.blank?
    end
    private
    def infer_title
    if helpers.action_name == 'index'
    title t("page_title_#{helpers.controller_name.demodulize}")

    View Slide

  72. class PageHeaderComponent < ApplicationComponent
    renders_many :breadcrumb_items, PageHeaderBreadcrumbComponent
    renders_one :actions_slot
    alias actions with_actions_slot
    def title(title)
    @title = helpers.t_display(title)
    helpers.content_for(:page_title, @title)
    end
    def breadcrumbs(*args)
    args.each { with_breadcrumb_item(_1) }
    end
    def before_render
    infer_title if @title.blank?
    end
    private
    def infer_title
    if helpers.action_name == 'index'
    title t("page_title_#{helpers.controller_name.demodulize}")
    def t_display(to_display)
    if to_display.is_a?(Symbol)
    t(to_display)
    elsif to_display.is_a?(String)
    to_display
    elsif to_display.respond_to?(:display_name)
    to_display.display_name
    elsif to_display.respond_to?(:name)
    to_display.name
    elsif to_display.respond_to?(:title)
    to_display.title
    else
    to_display.to_s
    end
    end

    View Slide

  73. class PageHeaderComponent < ApplicationComponent
    renders_many :breadcrumb_items, PageHeaderBreadcrumbComponent
    renders_one :actions_slot
    alias actions with_actions_slot
    def title(title)
    @title = helpers.t_display(title)
    helpers.content_for(:page_title, @title)
    end
    def breadcrumbs(*args)
    args.each { with_breadcrumb_item(_1) }
    end
    def before_render
    infer_title if @title.blank?
    end
    private
    def infer_title
    if helpers.action_name == 'index'
    title t("page_title_#{helpers.controller_name.demodulize}")

    View Slide

  74. class PageHeaderComponent < ApplicationComponent
    renders_many :breadcrumb_items, PageHeaderBreadcrumbComponent
    renders_one :actions_slot
    alias actions with_actions_slot
    def title(title)
    @title = helpers.t_display(title)
    helpers.content_for(:page_title, @title)
    end
    def breadcrumbs(*args)
    args.each { with_breadcrumb_item(_1) }
    end
    def before_render
    infer_title if @title.blank?
    end
    private
    def infer_title
    if helpers.action_name == 'index'
    title t("page_title_#{helpers.controller_name.demodulize}")

    View Slide

  75. alias actions with_actions_slot
    def title(title)
    @title = helpers.t_display(title)
    helpers.content_for(:page_title, @title)
    end
    def breadcrumbs(*args)
    args.each { with_breadcrumb_item(_1) }
    end
    def before_render
    infer_title if @title.blank?
    end
    private
    def infer_title
    if helpers.action_name == 'index'
    title t("page_title_#{helpers.controller_name.demodulize}")
    else
    resource_name = t(helpers.controller_name.demodulize.singularize).downcase
    title t("page_title_#{helpers.action_name}", resource_name: resource_name).capitalize
    end
    end
    end

    View Slide


  76. <% if breadcrumb_items.present? %>

    <% breadcrumb_items %>

    <% end %>


    <%= @title %>

    <% if actions_slot.present? %>

    <%= actions_slot %>

    <% end %>


    View Slide

  77. View Slide

  78. View Slide

  79. <%= component :table, @search.results do |table| %>
    <% table.record :name, :itself %>
    <% table.record :apartment %>
    <% table.number :document do |record| %>
    <% record.documents.each do |document| %>
    <%= link_to document.display_number, document_path(document) %>
    <% end %>
    <% end %>
    <% table.record :cashier, :user %>
    <% table.date :date %>
    <% table.money :cash_reserve_amount %>
    <% table.money :wallet_amount %>
    <% table.money :total_amount %>
    <% table.column :kind do |record| %>
    <%= component :transaction_kind_badge, record %>
    <% end %>
    <% table.actions do |record| %>
    <%= button_details transaction_path(record) %>
    <% end %>
    <% end %>

    View Slide

  80. <%= component :table, @search.results do |table| %>
    <% table.record :name, :itself %>
    <% table.record :apartment %>
    <% table.number :document do |record| %>
    <% record.documents.each do |document| %>
    <%= link_to document.display_number, document_path(document) %>
    <% end %>
    <% end %>
    <% table.record :cashier, :user %>
    <% table.date :date %>
    <% table.money :cash_reserve_amount %>
    <% table.money :wallet_amount %>
    <% table.money :total_amount %>
    <% table.column :kind do |record| %>
    <%= component :transaction_kind_badge, record %>
    <% end %>
    <% table.actions do |record| %>
    <%= button_details transaction_path(record) %>
    <% end %>
    <% end %>
    BadgeComponent (ui)
    TransactionKindBadgeComponent (domain)

    View Slide

  81. <%= component :table, @search.results do |table| %>
    <% table.record :name, :itself %>
    <% table.record :apartment %>
    <% table.number :document do |record| %>
    <% record.documents.each do |document| %>
    <%= link_to document.display_number, document_path(document) %>
    <% end %>
    <% end %>
    <% table.record :cashier, :user %>
    <% table.date :date %>
    <% table.money :cash_reserve_amount %>
    <% table.money :wallet_amount %>
    <% table.money :total_amount %>
    <% table.column :kind do |record| %>
    <%= component :transaction_kind_badge, record %>
    <% end %>
    <% table.actions do |record| %>
    <%= button_details transaction_path(record) %>
    <% end %>
    <% end %>
    module ApplicationHelper
    # ...
    def button_action(text, path, options = {})
    render ButtonComponent.new(text, path, :action, options)
    end
    def button_details(path, options = {})
    render ButtonComponent.new(:button_details, path, :action, options)
    end
    # ...
    end

    View Slide

  82. class TableComponent < ApplicationComponent
    attr_reader :records, :columns
    FORMAT_MONEY = -> { Format.money(_1) }
    FORMAT_DATE = -> { Format.date(_1) }
    def initialize(records)
    @records = records
    @columns = []
    end
    def before_render
    content
    end
    def column(name, classes = nil, format = nil, &)
    def number(name, &)
    def money(name, &)
    def date(name, options = {}, &block)
    def actions(&)
    def record(name, attribute_name = name)
    class TableColumn
    end

    View Slide

  83. class TableComponent < ApplicationComponent
    # ...
    def column(name, classes = nil, format = nil, &)
    @columns << TableColumn.new(name, classes, format, helpers, &)
    end
    def number(name, &)
    def money(name, &)
    def date(name, options = {}, &block)
    def actions(&)
    def record(name, attribute_name = name)
    class TableColumn
    end

    View Slide

  84. class TableComponent < ApplicationComponent
    # ...
    def number(name, &)
    column(name, 'number', &)
    end
    def money(name, &)
    column(name, 'number', FORMAT_MONEY)
    end
    def date(name, options = {}, &block)
    column(name, 'time', FORMAT_DATE, &)
    end
    def record(name, attribute_name = name)
    class TableColumn
    end

    View Slide

  85. class TableComponent < ApplicationComponent
    # ...
    def record(name, attribute_name = name)
    column(name) do |record|
    link_record = record.public_send(attribute_name)
    if link_record.present?
    helpers.link_to t_display(link_record), Routes.record_path(link_record)
    end
    end
    end
    class TableColumn
    end

    View Slide

  86. class TableComponent < ApplicationComponent
    class TableColumn
    def initialize(name, classes, format, helpers, &block)
    @name = name
    @classes = classes
    @format = format
    @helpers = helpers
    @block = block
    end
    def render_header
    @helpers.tag.th(@helpers.t_label(@name), role: 'col', class: classes)
    end
    def render_column(record)
    content = if @block
    @helpers.capture { @block.call(record).presence || '' }.strip
    else
    record.public_send(@name)
    end
    content = format.call(content) if content.present? && format.present?
    @helpers.tag.td(content, class: classes)
    end
    end

    View Slide




  87. <% columns.each do |column| %>
    <%= column.render_header %>
    <% end %>



    <% records.each do |record| %>

    <% columns.each do |column| %>
    <%= column.render_column(record) %>
    <% end %>

    <% end %>

    <% if records.respond_to?(:current_page) && records.total_pages > 1 %>



    <%= paginate records %>



    <% end %>

    View Slide

  88. View Slide

  89. https://rstankov.com/appearances

    View Slide