Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

Refactoring Volatile Views into Cohesive Compon...

Refactoring Volatile Views into Cohesive Components

It's easy for models to grow out of control, accumulating methods, attributes and responsibilities. But you know what can be worse? The view layer: a veritable wasteland of messy markup and leaking logic. Let's look at how to refactor that mess into clean, cohesive components with ViewComponent.

Jeremy Smith

June 08, 2024
Tweet

More Decks by Jeremy Smith

Other Decks in Programming

Transcript

  1. <%= link_to root_path, class: "group whitespace <%= tag.span(render("icons/home"), class: "text

    <%= tag.span("Dashboard", class: "text <% end %> <%= link_to contacts_path, class: "group whitespace <%= tag.span(render("icons/users"), class: "text <%= tag.span("Contacts", class: "text <%= tag.span(current_account.contacts, class: "text <% end %> <%= link_to companies_path, class: "group whitespace <%= tag.span(render("icons/building <%= tag.span("Companies", class: "text <%= tag.span(current_account.companies, class: "text <% end %> <%= link_to tasks_path, class: "group whitespace <%= tag.span(render("icons/pencil <%= tag.span("Tasks", class: "text <%= tag.span(current_account.tasks, class: "text <% end %> <% if current_account.plan <%= link_to reports_path, class: "group whitespace <%= tag.span(render("icons/chart <%= tag.span("Reports", class: "text <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on the Pro plan." }, class: "group whitespace <%= tag.span(render("icons/chart <%= tag.span("Reports", class: "text <% end %> <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h <%= tag.span(render("icons/cog-6-tooth"), class: "text <%= tag.span("Settings", class: "text <%= tag.span(render("icons/chevron <% end %> <%= tag.div(class: "hidden z-40 absolute mt-1 w-60 rounded <%= link_to "Collaborators", settings_collaborators_path, class: "block w <%= link_to "Notifications", settings_notifications_path, class: "block w <% end %> <% end %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w <% end %> <% end %> Refactoring Volatile Views into Cohesive Components
  2. Complexity Variation More options or variants for an attribute Proliferation

    More instances throughout the system Accumulation More attributes or responsibilities
  3. Dimensions of View Complexity Browsers/Clients Viewports Device Features Theming/Whitelabeling Dark/Light

    Mode Accessibility Internationalization SEO/Open Graph Authorization Entitlements Feature Flags Framework/Version Changes Execution Context Testing Affordances Design Systems/Tokens
  4. 🥲 <%= tag.nav(class: "flex justify - between space - x-2

    items - start border - b-2 border - gray-400") do %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= link_to root_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(contacts_path)}") %> <%= tag.span(current_account.contacts, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(companies_path) ? "border - b - red-400" : "border - b - gray-400"}" do <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(companies_path)}") %> <%= tag.span(current_account.companies, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none p-1 rounded - md # { current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% if current_account.plan = = "pro" %> <%= link_to reports_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(reports_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/chart - bar"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Reports", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(reports_path)}") %> <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on the Pro plan." }, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-100 border - b-2 border - <%= tag.span(render("icons/chart - bar"), class: "text - gray-300") %> <%= tag.span("Reports", class: "text - gray-400") %> <% end %> <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h - full group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(settings_path) ? "border - b - red-400" : "border - b - gray-400"}", data <%= tag.span(render("icons/cog-6-tooth"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Settings", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(settings_path)}") %> <%= tag.span(render("icons/chevron - down"), class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= tag.div(class: "hidden z-40 absolute mt-1 w-60 rounded - md border-2 border - gray-300 shadow - lg py-1 bg - gray-200 divide - y divide - gray-200", data: { toggle_target: "toggleable" }) do %> <%= link_to "Collaborators", settings_collaborators_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <%= link_to "Notifications", settings_notifications_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <% end %> <% end %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm border-2 border - gray-300 bg - gray-50 rounded - md shadow - inner placeholder:text - gray-400" %> <% end %> <% end %> <% end %> <% end %>
  5. Types of Components Layout Used for composing and positioning Model-Speci

    fi c Coupled to domain models Utility General-purpose, common UI patterns
  6. class AlertComponent < ApplicationComponent CLASSES = { notice: { background:

    "bg - emerald-200", icon: "text - emerald-700", text: "text - emerald-800" }, info: { background: "bg - cyan-200", icon: "text - cyan-700", text: "text - cyan-800" }, warning: { background: "bg - yellow-200", icon: "text - yellow-700", text: "text - yellow-800" }, alert: { background: "bg - rose-200", icon: "text - rose-700", text: "text - rose-800" } }.freeze renders_one :message def initialize(type:, title: nil, icon: nil) @type, @title, @icon = type, title, icon end private attr_reader :type, :title, :icon def color(element) CLASSES.dig(type, element) end def wrapper_classes class_names(color(:background), "flex items - center space - x-4 p-4 mb-4 rounded - md") end …
  7. <%= tag.div(class: wrapper_classes) do %> <%= tag.div(render("icons/ #{ icon}"), class:

    icon_classes) if icon.present? %> <%= tag.div do %> <%= tag.h3(title, class: title_classes) if title.present? %> <%= tag.p(message, class: message_classes) %> <% end %> <% end %>
  8. <%= render AlertComponent.new(type: :notice) .with_message_content("Contact address successfully updated.") %> <%=

    render AlertComponent.new(type: :info, icon: "cake") .with_message_content("John’s birthday is tomorrow. Don’t forget to send him a message!") %> <%= render AlertComponent.new(type: :alert, title: "Danger Zone") do |c| %> <%= c.with_message do %> Please be careful in the section below, as these actions are <strong>not reversible </ strong>! <% end %> <% end %> <%= render AlertComponent.new(type: :warning, icon: "information - circle", title: "Contact Limit Reached") do |c| %> <%= c.with_message do %> You’ve reached the contact limit for your plan. Please <%= link_to "upgrade", root_path, class: "underline" %> to add more contacts. <% end %> <% end %>
  9. class PageLayouts :: BasicComponent < ApplicationComponent renders_one :action renders_one :body

    def initialize(title:, parent_title: nil, parent_link: nil) @title = title @parent = Parent.new(parent_title, parent_link) if parent_title.present? end private attr_reader :title, :parent class Parent def initialize(title, link) @title = title @link = link end attr_reader :title, :link end end
  10. <%= tag.div(class: "bg - white p-6 rounded - md mb-10")

    do %> <%= tag.div(class: "flex justify - between items - end pb-4 border - b-2 border - b - gray-200 mb-6") do %> <%= tag.div do %> <%= tag.h3(class: "flex items - center text - lg leading - none font - semibold h-8") do %> <% if parent.present? %> <%= link_to parent.title, parent.link, class: "text - cyan-500" %> <%= tag.span(render("icons/divider"), class: "text - gray-300") %> <% end %> <%= tag.span(title) %> <% end %> <% end %> <%= tag.div(action, class: "space - x-2") %> <% end %> <%= body %> <% end %>
  11. <%= render PageLayouts :: BasicComponent.new(title: "Companies") do |c| %> <%=

    c.with_action do %> <%= link_to "New", new_company_path, class: "block bg - cyan-500 hover:bg - cyan-700 px-3 py-1 text - white <% end %> <%= c.with_body do %> <table class="min - w - full"> <thead class="bg - gray-600"> <tr> <th class="px-3 py-1.5 text - left text - sm text - white font - semibold">Name </ th> <th class="px-3 py-1.5 text - left text - sm text - white font - semibold">Size </ th> <th class="px-3 py-1.5 text - left text - sm text - white font - semibold">Website </ th> </ tr> </ thead> <tbody class="divide - y-2 divide - gray-100"> <% @companies.each do |company| %> <tr> <td class="px-3 py-1.5"><%= link_to company.name, company_path(company), class: "text - cyan-50 … </ tr> <% end %> </ tbody> </ table> <% end %> <% end %>
  12. class ContactCardComponent < ApplicationComponent with_collection_parameter :contact def initialize(contact:) @contact =

    contact end private attr_reader :contact class AvatarComponent < ApplicationComponent def initialize(contact:) @contact = contact end def call if contact_avatar? image_tag(contact_avatar, class: image_classes, alt: contact.full_name) else tag.div(tag.div(contact.first_initial, class: fallback_classes), class: wrapper_classes) end end private
  13. private attr_reader :contact def contact_avatar? contact.avatar.attached? && contact.avatar.variable? end def

    contact_avatar contact.avatar.variant(resize_to_fit: [200, 200]) end def image_classes "w-20 h-20 rounded - full" end def wrapper_classes "w-20 h-20 rounded - full bg - cyan-700 flex justify - center items - center" end def fallback_classes "text-5xl font - semibold text - cyan-100 text - center" end end end
  14. <%= link_to contact_path(contact), class: "flex flex - col block bg

    - white shadow rounded - md p-4 divide - y divide - gray-200" do %> <%= tag.div(class: "flex justify - between space - x-2") do %> <%= tag.div do %> <%= tag.div(class: "flex items - baseline space - x-2") do %> <%= tag.span(contact.full_name, class: "text - xl font - semibold text - gray-600") %> <%= tag.span(contact.company.name, class: "text - gray-400") %> <% end %> <% if contact.location.present? %> <%= tag.div(class: "flex space - x-2 items - center mt-2") do %> <%= tag.span(render("icons/map - pin"), class: "text - gray-400") %> <%= tag.div(contact.location, class: "text - sm text - gray-500") %> <% end %> <% end %> <% if contact.email.present? %> <%= tag.div(class: "flex space - x-2 items - center mt-2") do %> <%= tag.span(render("icons/envelope"), class: "text - gray-400") %> <%= tag.div(contact.email, class: "text - sm text - cyan-500") %> <% end %> <% end %> <% if contact.phone.present? %> <%= tag.div(class: "flex space - x-2 items - center mt-2") do %> <%= tag.span(render("icons/phone"), class: "text - gray-400") %> <%= tag.div(contact.phone, class: "text - sm text - gray-500") %> <% end %> <% end %> <% end %> <%= tag.div(render(AvatarComponent.new(contact: contact))) %> <% end %> <% if contact.social? %> <%= tag.div(class: "flex space - x-3 items - center mt-4 pt-4") do %> <% if contact.linkedin.present? %> <%= tag.span(render("icons/linkedin"), class: "text - gray-400") %> <% end %> <% if contact.github.present? %> <%= tag.span(render("icons/github"), class: "text - gray-400") %> <% end %> <% if contact.twitter.present? %> <%= tag.span(render("icons/twitter"), class: "text - gray-400") %> <% end %> <% end %> <% end %> <% end %>
  15. <%= tag.div(class: "flex flex - col space - y-4 max

    - w - xl") do %> <%= render ContactCardComponent.with_collection(@contacts) %> <% end %>
  16. <%= tag.nav(class: "flex space - x-2 …") do %> <%=

    link_to "Dashboard", root_path, class: "whitespace - nowrap flex …" %> <%= link_to "Contacts", contacts_path, class: "whitespace - nowrap flex …" %> <%= link_to "Companies", companies_path, class: "whitespace - nowrap flex …" %> <%= link_to "Tasks", tasks_path, class: "whitespace - nowrap flex …" %> <% end %>
  17. <%= tag.nav(class: "flex space - x-2 …") do %> <%=

    link_to "Dashboard", root_path, class: "whitespace - nowrap flex …" %> <%= link_to "Contacts", contacts_path, class: "whitespace - nowrap flex …" %> <%= link_to "Companies", companies_path, class: "whitespace - nowrap flex …" %> <%= link_to "Tasks", tasks_path, class: "whitespace - nowrap flex …" %> <% end %>
  18. <%= tag.nav(class: "flex space - x-2 …") do %> <%=

    link_to root_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <% end %>
  19. <%= tag.nav(class: "flex space - x-2 …") do %> <%=

    link_to root_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <% end %>
  20. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "… #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/users"), class: "text - gray-400 …") %> <%= tag.span("Contacts", class: "… #{ "font - semibold" if current_page?(contacts_path)}") %> <% end %> <%= link_to companies_path, class: "… #{ current_page?(companies_path) ? "border - b - red-400" : "border - - <%= tag.span(render("icons/building - office"), class: "text - gray-400 …") %> <%= tag.span("Companies", class: "… #{ "font - semibold" if current_page?(companies_path)}") %> <% end %> <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <% end %> <% end %> <% end %>
  21. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "… #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/users"), class: "text - gray-400 …") %> <%= tag.span("Contacts", class: "… #{ "font - semibold" if current_page?(contacts_path)}") %> <% end %> <%= link_to companies_path, class: "… #{ current_page?(companies_path) ? "border - b - red-400" : "border - - <%= tag.span(render("icons/building - office"), class: "text - gray-400 …") %> <%= tag.span("Companies", class: "… #{ "font - semibold" if current_page?(companies_path)}") %> <% end %> <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <% end %> <% end %> <% end %>
  22. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "… #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/users"), class: "text - gray-400 …") %> <%= tag.span("Contacts", class: "… #{ "font - semibold" if current_page?(contacts_path)}") %> <%= tag.span(current_account.contacts, class: "text - xs leading - none …") %> <% end %> … <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none … #{ current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% end %> <% end %>
  23. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> … <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none … #{ current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% end %> <% end %>
  24. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> … <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none … #{ current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% if current_account.plan == "pro" %> <%= link_to reports_path, class: "… #{ current_page?(reports_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/chart - bar"), class: "text - gray-400 …") %> <%= tag.span("Reports", class: "… #{ "font - semibold" if current_page?(reports_path)}") %> <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on t <%= tag.span(render("icons/chart - bar"), class: "text - gray-300") %> <%= tag.span("Reports", class: "text - gray-400") %> <% end %> <% end %> <% end %> <% end %>
  25. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> … <% if current_account.plan == "pro" %> … <% end %> <% end %> <% end %>
  26. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> … <% if current_account.plan == "pro" %> … <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h - full … #{ current_page?(settings_path) ? "border - b - red-400" : "border - b - g <%= tag.span(render("icons/cog-6-tooth"), class: "text - gray-400 …") %> <%= tag.span("Settings", class: "… #{ "font - semibold" if current_page?(settings_path)}") %> <%= tag.span(render("icons/chevron - down"), class: "…") %> <% end %> <%= tag.div(class: "hidden z-40 …", data: { toggle_target: "toggleable" }) do %> <%= link_to "Collaborators", settings_collaborators_path, class: "block w - full …" %> <%= link_to "Notifications", settings_notifications_path, class: "block w - full …" %> <% end %> <% end %> <% end %> <% end %> <% end %>
  27. <%= tag.nav(class: "flex border - b-2 …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> … <% if current_user.admin? %> … <% end %> <% end %> <% end %>
  28. <%= tag.nav(class: "flex justify - between …") do %> <%=

    tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> … <% if current_user.admin? %> … <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm <% end %> <% end %> <% end %> <% end %>
  29. <%= tag.nav(class: "flex justify - between space - x-2 items

    - start border - b-2 border - gray-400") do %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= link_to root_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(contacts_path)}") %> <%= tag.span(current_account.contacts, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(companies_path) ? "border - b - red-400" : "border - b - gray-400"}" do <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(companies_path)}") %> <%= tag.span(current_account.companies, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none p-1 rounded - md # { current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% if current_account.plan = = "pro" %> <%= link_to reports_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(reports_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/chart - bar"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Reports", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(reports_path)}") %> <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on the Pro plan." }, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-100 border - b-2 border - <%= tag.span(render("icons/chart - bar"), class: "text - gray-300") %> <%= tag.span("Reports", class: "text - gray-400") %> <% end %> <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h - full group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(settings_path) ? "border - b - red-400" : "border - b - gray-400"}", data <%= tag.span(render("icons/cog-6-tooth"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Settings", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(settings_path)}") %> <%= tag.span(render("icons/chevron - down"), class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= tag.div(class: "hidden z-40 absolute mt-1 w-60 rounded - md border-2 border - gray-300 shadow - lg py-1 bg - gray-200 divide - y divide - gray-200", data: { toggle_target: "toggleable" }) do %> <%= link_to "Collaborators", settings_collaborators_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <%= link_to "Notifications", settings_notifications_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <% end %> <% end %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm border-2 border - gray-300 bg - gray-50 rounded - md shadow - inner placeholder:text - gray-400" %> <% end %> <% end %> <% end %> <% end %>
  30. Variation Tab type, selected state, authorization, etc. Proliferation Team wants

    to use this in another section Accumulation Icon, counter, dropdown, non-tabs, etc. Churn Changed seven times, may not be done
  31. class TabNav :: BarComponent < ApplicationComponent renders_many :tabs renders_one :extra

    private def nav_classes "flex justify - between space - x-2 items - start border - b-2 border - gray-400" end def section_classes "flex - mb-0.5 space - x-2" end end
  32. <%= tag.nav(class: nav_classes) do %> <%= tag.div(class: section_classes) do %>

    <% tabs.each do |tab| %> <%= tab %> <% end %> <% end %> <% if extra? %> <%= tag.div(class: section_classes) do %> <%= extra %> <% end %> <% end %> <% end %>
  33. class TabNav :: BarComponent < ApplicationComponent renders_many :tabs, types: {

    link: TabNav :: LinkTabComponent, disabled: TabNav :: DisabledTabComponent, dropdown: TabNav :: DropdownTabComponent } renders_one :extra private def nav_classes "flex justify - between space - x-2 items - start border - b-2 border - gray-400" end def section_classes "flex - mb-0.5 space - x-2" end end
  34. class TabNav :: BaseTabComponent < ApplicationComponent private attr_reader :text, :icon,

    :selected def text_classes class_names("text - gray-600 group - hover:text - gray-700", "font - semibold": selected) end def icon_svg render(“icons/ #{ icon}") end def icon_classes "text - gray-400 group - hover:text - gray-500" end def tab_classes(*args) class_names(tab_base, * args) end … end
  35. class TabNav :: LinkTabComponent < TabNav :: BaseTabComponent def initialize(link:,

    text:, icon: nil, selected: false, counter: nil, threshold: nil) @link, @text, @icon, @selected, @counter, @threshold = link, text, icon, selected, counter, threshold end private attr_reader :link, :counter, :threshold def tab_classes super(tab_enabled, " #{ tab_selected}": selected, " #{ tab_unselected}": !selected) end def counter_classes class_names("text - xs leading - none p-1 rounded - md", threshold_classes) end def threshold_classes exceeds_threshold? ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600" end def exceeds_threshold? return false if counter.blank? || threshold.blank? counter > threshold end end
  36. <%= link_to link, class: tab_classes do %> <%= tag.span(icon_svg, class:

    icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <%= tag.span(counter, class: counter_classes) if counter.present? %> <% end %>
  37. class TabNav :: DisabledTabComponent < TabNav :: BaseTabComponent def initialize(text:,

    icon: nil, tooltip: nil) @text, @icon, @tooltip = text, icon, tooltip end private attr_reader :tooltip def text_classes "text - gray-400" end def icon_classes "text - gray-300" end def tab_classes super(" #{ tab_disabled}": true, " #{ tab_unselected}": true) end def tab_disabled "bg - gray-100 cursor - not - allowed" end end
  38. <%= tag.span data: { controller: "tooltip", tooltip_content_value: tooltip }, class:

    tab_classes do %> <%= tag.span(icon_svg, class: icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <% end %>
  39. class TabNav :: DropdownTabComponent < TabNav :: BaseTabComponent renders_many :items,

    "DropdownItemComponent" def initialize(text:, icon: nil, selected: false) @text, @icon, @selected = text, icon, selected end … class DropdownItemComponent < ApplicationComponent def initialize(text:, link:) @text, @link = text, link end def call link_to text, link, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 ho end private attr_reader :text, :link end end
  40. <%= tag.span data: { controller: "toggle", toggle_toggle_class: hidden_class }, class:

    wrapper_classes do <%= tag.button(class: tab_classes, data: { action: "click -> toggle#toggle click@window -> toggle#hide page <%= tag.span(icon_svg, class: icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <%= tag.span(caret_svg, class: caret_classes) %> <% end %> <%= tag.div(class: menu_classes, data: { toggle_target: "toggleable" }) do %> <% items.each do |item| %> <%= item %> <% end %> <% end %> <% end %>
  41. <%= render TabNav : : BarComponent.new do |c| %> <%

    c.with_tab_link(text: "Dashboard", link: root_path, selected: current_page?(tab_path), icon: "home") %> <% c.with_tab_link(text: "Contacts", link: contacts_path, selected: current_page?(contacts_path), icon: "users <% c.with_tab_link(text: "Companies", link: companies_path, selected: current_page?(companies_path), icon: "bu <% c.with_tab_link(text: "Tasks", link: tasks_path, selected: current_page?(tasks_path), icon: "pencil - square" <% if current_account.plan == "pro" %> <% c.with_tab_link(text: "Reports", link: tasks_path, selected: current_page?(reports_path), icon: "chart - ba <% else %> <% c.with_tab_disabled(text: "Reports", icon: "chart - bar", tooltip: "Reports are only available on the Pro p <% end %> <% if current_user.admin? %> <% c.with_tab_dropdown(text: "Settings", selected: current_page?(settings_path), icon: "cog-6-tooth") do |d| <% d.with_item(text: "Collaborators", link: settings_collaborators_path) %> <% d.with_item(text: "Notifications", link: settings_notifications_path) %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <% c.with_extra do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm border <% end %> <% end %> <% end %> <% end %>
  42. class TabNav :: DropdownTabComponent < TabNav :: BaseTabComponent renders_many :items,

    "DropdownItemComponent" def initialize(text:, icon: nil, selected: false) @text, @icon, @selected = text, icon, selected end … class DropdownItemComponent < ApplicationComponent def initialize(text:, link:) @text, @link = text, link end def call link_to text, link, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 ho end private attr_reader :text, :link end end
  43. <%= tag.span data: { controller: "toggle", toggle_toggle_class: hidden_class }, class:

    wrapper_classes do <%= tag.button(class: tab_classes, data: { action: "click -> toggle#toggle click@window -> toggle#hide page <%= tag.span(icon_svg, class: icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <%= tag.span(caret_svg, class: caret_classes) %> <% end %> <%= tag.div(class: menu_classes, data: { toggle_target: "toggleable" }) do %> <% items.each do |item| %> <%= item %> <% end %> <% end %> <% end %>
  44. app/views/tab_nav/_dropdown_tab.html.erb <%# locals: (text:, icon: nil, selected: false) -%> <%=

    tag.span data: { controller: "toggle", toggle_toggle_class: tab_nav_dropdown_hidden_class }, class: t <%= tag.button(class: tab_nav_tab_classes(dropdown: true, selected: selected), data: { action: "click -> <%= tag.span(tab_nav_icon_svg(icon), class: tab_nav_icon_classes) if icon.present? %> <%= tag.span(text, class: tab_nav_text_classes(selected: selected)) %> <%= tag.span(tab_nav_dropdown_caret_svg, class: tab_nav_dropdown_caret_classes) %> <% end %> <%= tag.div(class: tab_nav_dropdown_menu_classes, data: { toggle_target: "toggleable" }) do %> <%= yield %> <% end %> <% end %>
  45. <%= render "tab_nav/bar", tabs: capture { %> <%= render "tab_nav/link_tab",

    text: "Dashboard", link: root_path, selected: current_page?(tab_path), i … <% }, extra: capture { %> <% if current_account.enabled_feature?(:search) %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm b <% end %> <% end %> <% } %>
  46. module TabNavHelper def tab_nav_text_classes(selected: false, disabled: false) class_names( "text -

    gray-600 group - hover:text - gray-700": !disabled, "font - semibold": selected, "text - gray-400": disabled ) end def tab_nav_icon_svg(icon) render("icons/ #{ icon}") end def tab_nav_icon_classes(disabled: false) disabled ? "text - gray-300" : "text - gray-400 group - hover:text - gray-500" end def tab_nav_tab_classes(selected: false, disabled: false, dropdown: false) class_names( "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 border - b-2", "h - full": dropdown, "bg - gray-300 hover:bg - gray-400": !disabled, "bg - gray-100 cursor - not - allowed": disabled, "border - b - red-400": selected, "border - b - gray-400": !selected ) end def tab_nav_dropdown_wrapper_classes "relative" end def tab_nav_dropdown_caret_svg render("icons/chevron - down") end def tab_nav_dropdown_caret_classes "text - gray-600 group - hover:text - gray-700" end def tab_nav_dropdown_menu_classes class_names( tab_nav_dropdown_hidden_class, "z-40 absolute mt-1 w-60 rounded - md border-2 border - gray-300 shadow - lg py-1 bg - gray-200 divide - y divide - gray-200" ) end def tab_nav_dropdown_hidden_class "hidden" end def tab_nav_dropdown_item_classes "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" end end
  47. Summary 1. Implement designs in traditional templates 2. Watch for

    volatility (high churn & complexity) 3. Extract view components and regain stability
  48. require "test_helper" class TabNav :: BarComponentTest < ViewComponent :: TestCase

    def test_render_component render_inline(TabNav :: BarComponent.new) do |c| c.with_tab_link(text: "Home", link: "/home") c.with_tab_link(text: "Reports", link: "/reports", icon: "chart - bar") c.with_extra { "<strong>Something else. </ strong>".html_safe } end assert_selector("nav") assert_selector("a", count: 2) assert_link("Home", href: "/home") assert_link("Reports", href: "/reports") assert_selector("svg", count: 1) assert_selector("strong", text: "Something else.") end end
  49. Other Libraries Phlex 
 https://github.com/phlex-ruby/phlex Nice Partials 
 https://github.com/bullet-train-co/ nice_partials

    Komponent 
 https://github.com/komposable/komponent Matestack 
 https://github.com/matestack Cells 
 https://github.com/trailblazer/cells Jumpstart Pro JumpstartComponent
  50. Resources https://viewcomponent.org/ https://github.com/viewcomponent/view_component https://github.com/palkan/view_component-contrib https://github.com/pantographe/view_component-form https://github.com/phlex-ruby/phlex https://github.com/bullet-train-co/nice_partials https://github.com/trailblazer/cells https://github.com/komposable/komponent https://github.com/matestack

    https://evilmartians.com/chronicles/viewcomponent-in-the-wild-building-modern-rails-frontends https://evilmartians.com/chronicles/viewcomponent-in-the-wild-supercharging-your-components https://evilmartians.com/chronicles/viewcomponent-in-the-wild-embracing-tailwindcss-classes-and-html-attributes https://railsnotes.xyz/blog/rails-viewcomponent-tips https://dev.to/nejremeslnici/from-partials-to-viewcomponents-writing-reusable-front-end-code-in-rails-1c9o https://www.honeybadger.io/blog/rails-viewcomponent/ https://thoughtbot.com/blog/hotwire-turbo-streaming-viewcomponents https://tips.rstankov.com/p/tips-for-using-viewcomponents-in https://viewcomponent.org/viewcomponents-at-github.html https://www.codewithjason.com/the-problem-that-viewcomponent-solves-for-me/ https://evilmartians.com/chronicles/evil-front-part-1#block-mentality https://bigmedium.com/ideas/design-system-pace-layers-slow-fast.html https://github.com/whitesmith/rubycritic/blob/main/docs/core-metrics.md#churn-and-complexity https://www.railsinside.com/tutorials/487-how-to-score-your-rails-apps-complexity-before-refactoring.html https://www.youtube.com/watch?v=ar8RMbDPoSY Slides: https://speakerdeck.com/rstankov/component-driven-ui-with-viewcomponent-gem https://www.youtube.com/watch?v=sIxvxp7E0xg Slides: https://speakerdeck.com/palkan/railsconf-2021-frontendless-rails-frontend https://www.youtube.com/watch?v=YVYRus_2KZM https://www.youtube.com/watch?v=QoetqsBCsbE