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. !

  2. Angry Building Architecture # $ Ruby on Rails % No

    JavaScript (except rails-ujs) & No CSS (except Tailwind) ' Extensive e2e tests ( Focus on domain
  3. <%= render FieldsetComponent.new(fieldset, title: :bank_account) do %> <%= form.input :bank_account_bank

    %> <%= form.input :bank_account_iban %> <%= form.input :bank_account_bic %> <% end %>
  4. <fieldset class="c-box p-4"> <legend class="c-box px-3 py-1"> <%= title %>

    </legend> <div class="space-y-6"> <%= content %> </div> </fieldset> app/components/fieldset_component.html.erb
  5. <fieldset class="c-box p-4"> <legend class="c-box px-3 py-1"> <%= title %>

    </legend> <div class="space-y-6"> <%= content %> </div> </fieldset> app/components/fieldset_component.html.erb Where content comes from? *
  6. 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
  7. <%= render 'hero'%> <%= render 'primary_features' %> <%= render 'clients'

    %> <%= render 'features' %> <%= render 'mobile_app' %> <%= render 'pricing' %> <%= render 'subscribe_form' %> <%= render 'faq' %>
  8. <%= render HomepageHeroComponent.new %> <%= render HomepagePrimaryFeaturesComponent.new %> <%= render

    HomepageClientsComponent.new %> <%= render HomepageFeaturesComponent.new %> <%= render HomepageMobileAppComponent.new %> <%= render HomepagePricing.new %> <%= render HomepageSubscribeForm.new %> <%= render HomepageFaq.new %>
  9. <%= render HomepageHeroComponent.new %> <%= render HomepagePrimaryFeaturesComponent.new %> <%= render

    HomepageClientsComponent.new %> <%= render HomepageFeaturesComponent.new %> <%= render HomepageMobileAppComponent.new %> <%= render HomepagePricing.new %> <%= render HomepageSubscribeForm.new %> <%= render HomepageFaq.new %>
  10. + 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 (domain in view) / 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)
  11. <%= 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 %>
  12. <%= 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
  13. <%= 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 %>
  14. <%= 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, **@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), c <% form.select :source, placeholder: :source, options: @search.source_options, classes: 'w- <% 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,
  15. <%= 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, **@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), c <% form.select :source, placeholder: :source, options: @search.source_options, classes: 'w- <% 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 %>
  16. <% 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 %>
  17. <% 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 %>
  18. 7 Slots class StatsComponent < ApplicationComponent renders_many :numbers, StatsNumberComponent alias

    number with_number end StatsNumber Stats StatsNumber StatsNumber StatsNumber ...
  19. 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 ...
  20. 7 Slots class StatsComponent < ApplicationComponent renders_many :numbers, StatsNumberComponent alias

    number with_number end StatsNumber Stats StatsNumber StatsNumber StatsNumber ...
  21. 8 Tip: Slots <dl class="c-stats"> <% numbers.each do |_1| %>

    <%= _1 %> <% end %> </dl> 7 Slots StatsNumber Stats StatsNumber StatsNumber StatsNumber ...
  22. 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
  23. 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
  24. <% 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 %>
  25. <% 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),
  26. <% 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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? *
  33. 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
  34. 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
  35. 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
  36. <%= form_tag @action, method: :get, class: 'c-filter-form' do %> <%

    inputs.each do |(label, input)| %> <label class="flex items-center gap-x-2 c-hint"> <%= t_label(label) %> <%= input %> </label> <% end %> <%= submit_tag t(:button_filter), name: nil, class: 'c-button' %> <% end %>
  37. <%= 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 %>
  38. class PageHeaderComponent < ApplicationComponent renders_many :breadcrumb_items, PageHeaderBreadcrumbComponent renders_one :actions_slot alias

    actions with_actions_slot def title(title) @title = 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}")
  39. class PageHeaderComponent < ApplicationComponent renders_many :breadcrumb_items, PageHeaderBreadcrumbComponent renders_one :actions_slot alias

    actions with_actions_slot def title(title) @title = 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
  40. 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}")
  41. 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}")
  42. 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
  43. <div class="c-page-header"> <% if breadcrumb_items.present? %> <nav aria-label="Breadcrumb"> <% breadcrumb_items

    %> </nav> <% end %> <header> <h1> <%= @title %> </h1> <% if actions_slot.present? %> <div> <%= actions_slot %> </div> <% end %> </header> </div>
  44. <%= 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 %>
  45. <%= 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)
  46. <%= 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
  47. 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
  48. 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
  49. class TableComponent < ApplicationComponent # ... def number(name, &) column(name,

    'number', &) end def money(name, &) column(name, 'number', FORMAT_MONEY, &) end def date(name, options = {}, &) column(name, 'time', FORMAT_DATE, &) end def record(name, attribute_name = name) class TableColumn end
  50. 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
  51. 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_cell(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
  52. <table class="c-table"> <thead class="header"> <tr valign="top"> <% columns.each do |column|

    %> <%= column.render_header %> <% end %> </tr> </thead> <tbody> <% records.each do |record| %> <tr valign="top"> <% columns.each do |column| %> <%= column.render_cell(record) %> <% end %> </tr> <% end %> </tbody> <% if records.respond_to?(:current_page) && records.total_pages > 1 %> <tfoot> <tr> <td colspan="<%= columns.size %>"> <%= paginate records %> </td> </tr> </tfoot> <% end %> </table>