Slide 1

Slide 1 text

Component-Driven UI with ViewComponent Gem Radoslav Stankov

Slide 2

Slide 2 text

!

Slide 3

Slide 3 text

Radoslav Stankov @rstankov rstankov.com

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

https://tips.rstankov.com

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

Product Hunt Architecture

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

" Side project

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

I ❤

Slide 12

Slide 12 text

I ❤

Slide 13

Slide 13 text

No content

Slide 14

Slide 14 text

No content

Slide 15

Slide 15 text

No content

Slide 16

Slide 16 text

No content

Slide 17

Slide 17 text

No content

Slide 18

Slide 18 text

No content

Slide 19

Slide 19 text

No content

Slide 20

Slide 20 text

Title Content

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

<%= title %>
<%= content %>
app/components/fieldset_component.html.erb

Slide 24

Slide 24 text

<%= title %>
<%= content %>
app/components/fieldset_component.html.erb Where content comes from? *

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

No content

Slide 27

Slide 27 text

No content

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

No content

Slide 30

Slide 30 text

No content

Slide 31

Slide 31 text

What goes to helper? What goes to partial? What goes to component?

Slide 32

Slide 32 text

<%= render 'hero'%> <%= render 'primary_features' %> <%= render 'clients' %> <%= render 'features' %> <%= render 'mobile_app' %> <%= render 'pricing' %> <%= render 'subscribe_form' %> <%= render 'faq' %>

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

+ 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)

Slide 36

Slide 36 text

1) Helpers 2) UI Components 3) Domain Components

Slide 37

Slide 37 text

format_money MoneyComponent ProductPriceComponent

Slide 38

Slide 38 text

No content

Slide 39

Slide 39 text

No content

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

No content

Slide 43

Slide 43 text

No content

Slide 44

Slide 44 text

Navigation Page Header Filter Form Stats Table

Slide 45

Slide 45 text

NavigationComponent PageHeaderComponent FilterFormComponent StatsComponent TableComponent

Slide 46

Slide 46 text

<%= 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 %>

Slide 47

Slide 47 text

<%= 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

Slide 48

Slide 48 text

No content

Slide 49

Slide 49 text

No content

Slide 50

Slide 50 text

No content

Slide 51

Slide 51 text

<%= 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 %>

Slide 52

Slide 52 text

<%= 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,

Slide 53

Slide 53 text

<%= 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 %>

Slide 54

Slide 54 text

<% 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 %>

Slide 55

Slide 55 text

<% 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 %>

Slide 56

Slide 56 text

7 Slots StatsNumber Stats StatsNumber StatsNumber StatsNumber ...

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

8 Tip: Slots
<% numbers.each do |_1| %> <%= _1 %> <% end %>
7 Slots StatsNumber Stats StatsNumber StatsNumber StatsNumber ...

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

No content

Slide 64

Slide 64 text

No content

Slide 65

Slide 65 text

<% 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 %>

Slide 66

Slide 66 text

<% 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),

Slide 67

Slide 67 text

<% 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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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? *

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

<%= 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 %>

Slide 78

Slide 78 text

No content

Slide 79

Slide 79 text

No content

Slide 80

Slide 80 text

<%= 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 %>

Slide 81

Slide 81 text

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}")

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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}")

Slide 84

Slide 84 text

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}")

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

<% if breadcrumb_items.present? %> <% breadcrumb_items %> <% end %>

<%= @title %>

<% if actions_slot.present? %>
<%= actions_slot %>
<% end %>

Slide 87

Slide 87 text

No content

Slide 88

Slide 88 text

No content

Slide 89

Slide 89 text

<%= 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 %>

Slide 90

Slide 90 text

<%= 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)

Slide 91

Slide 91 text

<%= 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

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

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

Slide 95

Slide 95 text

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

Slide 96

Slide 96 text

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

Slide 97

Slide 97 text

<% columns.each do |column| %> <%= column.render_header %> <% end %> <% records.each do |record| %> <% columns.each do |column| %> <%= column.render_cell(record) %> <% end %> <% end %> <% if records.respond_to?(:current_page) && records.total_pages > 1 %> <%= paginate records %> <% end %>

Slide 98

Slide 98 text

No content

Slide 99

Slide 99 text

https://tips.rstankov.com

Slide 100

Slide 100 text

No content