DEV Community

Cover image for Simplify Rails Views Using ViewComponents with Tailwind CSS and RSpec
Stef Coetzee
Stef Coetzee

Posted on

Simplify Rails Views Using ViewComponents with Tailwind CSS and RSpec

I recently did a deep dive on refactoring Rails views to reusable view components.

I thought I’d share some of the components that were extracted from the example application used for this exercise with you here.

You can view the code for the refactored application in this repo.

LinkComponent and its children

To cut down on duplicate link styling, I created a LinkComponent template:

<%# app/components/link_component.html.erb %>

<div class="text-gray-600 transition duration-300 border-b border-white max-w-fit hover:border-b hover:border-gray-400">
  <%= content %>
</div>

Enter fullscreen mode Exit fullscreen mode

Since we’re just rendering styled content here, the associated spec is super simple:

# spec/components/link_component_spec.rb

require "rails_helper"

RSpec.describe LinkComponent, type: :component do
  it "renders text" do
    test_text = "some text"
    render_inline(described_class.new.with_content(test_text))

    expect(rendered_component).to have_text(test_text)
  end
end

Enter fullscreen mode Exit fullscreen mode

More specific link templates simply inherit from this base template. I decided to leave the link_to methods in the various link components that inherit from LinkComponent so that DeleteComponent can also be part of the family. Before we take a look at the various link components’ code, here they are in action in the show team view (app/views/teams/show.html.erb):

show team view with link components outlined

Four separate link templates are used in the view above. One each for:

  • Back navigation out of the show view,
  • Editing a team,
  • Deleting a team, and
  • Adding a player to the team.

All of their templates and specs are provided below, should you want to make use of something similar in your own projects.

Back navigation

Template:

<%# app/components/back_navigation_component.html.erb %>

<%= render LinkComponent.new do %>
  <%= link_to @href do %>
    <div class="flex items-center space-x-1">
      <svg class="w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
          xmlns="http://www.w3.org/2000/svg">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
              d="M10 19l-7-7m0 0l7-7m-7 7h18">
        </path>
      </svg>
      <div>
        Back to <%= @text %>
      </div>
    </div>
  <% end %>
<% end %>

Enter fullscreen mode Exit fullscreen mode

Spec:

# spec/components/back_navigation_component_spec.rb

require "rails_helper"

RSpec.describe BackNavigationComponent, type: :component do
  it "renders a back-navigation link" do
    test_url = "https://example.com"
    test_text = "team"
    render_inline(described_class.new(href: test_url, text: test_text))

    expect(rendered_component).to have_link(
      "Back to #{test_text}",
      href: test_url
    )
  end
end

Enter fullscreen mode Exit fullscreen mode

Edit link

Template:

<%# app/components/edit_link_component.html.erb %>

<%= render LinkComponent.new do %>
  <%= link_to @href do %>
    <div class="flex items-center space-x-1">
      <svg class="w-4" fill="none" stroke="currentColor"
        viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
              d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
        </path>
      </svg>
      <div>
        Edit <%= @text %>
      </div>
    </div>
  <% end %>
<% end %>

Enter fullscreen mode Exit fullscreen mode

Spec:

# spec/components/edit_link_component_spec.rb

require "rails_helper"

RSpec.describe EditLinkComponent, type: :component do
  it "renders an edit link" do
    test_url = "https://example.com"
    test_text = "player"
    render_inline(described_class.new(href: test_url, text: test_text))

    expect(rendered_component).to have_link "Edit #{test_text}", href: test_url
  end
end

Enter fullscreen mode Exit fullscreen mode

Delete link

Template:

<%# app/components/delete_link_component.html.erb %>

<%= render LinkComponent.new do %>
  <%= link_to @href, data: {
      turbo_method: :delete,
      turbo_confirm: "Are you sure?"
  } do %>
    <div class="flex items-center space-x-1">
      <svg class="w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
          xmlns="http://www.w3.org/2000/svg">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
              d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
        </path>
      </svg>
      <div>
        Delete <%= @text %>
      </div>
    </div>
  <% end %>
<% end %>

Enter fullscreen mode Exit fullscreen mode

Spec:

# spec/components/delete_link_component_spec.rb

require "rails_helper"

RSpec.describe DeleteLinkComponent, type: :component do
  it "renders a delete link" do
    test_url = "https://example.com"
    test_text = "player"
    render_inline(described_class.new(href: test_url, text: test_text))

    expect(rendered_component).to have_link "Delete #{test_text}", href: test_url
  end
end

Enter fullscreen mode Exit fullscreen mode

Add link

Template:

<%# app/components/add_link_component.html.erb %>

<%= render LinkComponent.new do %>
  <%= link_to @href do %>
    <div class="flex items-center space-x-1">
      <svg class="w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
          xmlns="http://www.w3.org/2000/svg">
        <path stroke-linecap="round"
              stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6">
        </path>
      </svg>
      <div>
        Add a <%= @text %>
      </div>
    </div>
  <% end %>
<% end %>

Enter fullscreen mode Exit fullscreen mode

Spec:

# spec/components/delete_link_component_spec.rb

require "rails_helper"

RSpec.describe AddLinkComponent, type: :component do
  it "renders an add link" do
    test_url = "https://example.com"
    test_text = "player"
    render_inline(described_class.new(href: test_url, text: test_text))

    expect(rendered_component).to have_link "Add a #{test_text}", href: test_url
  end
end

Enter fullscreen mode Exit fullscreen mode

A componentized page header

Once defined, component templates can easily be called. Here they are as part of the page header in the image up above:

<%# app/views/teams/show.html.erb %>

<div class="mb-2">
  <%= render PageHeaderComponent.new do %>
    <div class="mb-1">
    <%= render BackNavigationComponent.new(
          href: teams_path,
          text: "teams"
    ) %>
    </div>
    <div class="mb-1">
      <%= render PageTitleComponent.new(title: @team.name) %>
    </div>
    <div class="flex space-x-3">
      <%= render EditLinkComponent.new(href: edit_team_path(@team), text: "team") %>
      <%= render DeleteLinkComponent.new(href: team_path(@team), text: "team") %>
    </div>
  <% end %>
</div>
...
Enter fullscreen mode Exit fullscreen mode

As you might expect, the PageHeaderComponent and PageTitleComponent templates are super simple:

<%# app/components/page_header_component.html.erb %>

<div class="pb-2 border-b">
  <%= content %>
</div>

Enter fullscreen mode Exit fullscreen mode
<%# app/components/page_title_component.html.erb %>

<div class="text-xl font-bold">
  <%= @title %>
</div>

Enter fullscreen mode Exit fullscreen mode

That’s the beauty of working with ViewComponent: define once, use anywhere.

If you enjoyed this brief look at ViewComponent templates, you might enjoy the full post. Check out the start-here git branch and follow along from the comfort of your own editor as we refactor a fully-tested example application to reusable components.

Top comments (2)

Collapse
 
redbar0n profile image
Magne • Edited

This is the way. But now you are just one step away from doing it in JS/TS, with something like NextJS instead of Rails. Then you could even run those components on the client, not just the server. Truly "define once, use anywhere". [1]

The component code would be more readable too, since you don't need inline render statements and such:

<%= render BackNavigationComponent.new(
      href: teams_path,
      text: "teams"
) %>`
Enter fullscreen mode Exit fullscreen mode

but could have more HTML-like structure with JSX (where the component name is front and center):

<BackNavigationComponent href={teams_path} text="teams" />
Enter fullscreen mode Exit fullscreen mode

That would also let you work in the context of JS, instead of working inside the context of HTML where you'd need to inline Ruby. Phlex would be the similar approach in Ruby, but syntax is even better in JSX, imho. Plus with JSX (React/Next.js) you get to run the same code on the client too [2], if you wish (without needing something extra like Stimulus to hook into and modify the HTML after the fact).

If you only care about web dev (not mobile / react native), and want to be really cutting edge, then I'd ditch Next.js in favor of Qwik City. That would also let you run the same code on the server and the client, but Qwik City can also start the execution on the server, and resume it seamlessly on the client (and only ever download the minimal JS needed to the client, eliminating JS bundle size bloat).

[1] If you want to take the "write once, run anywhere" philosophy to the max, then with Tamagui you could even write once and run on iOS, Andoid as well as the Web! That's if you care about being fully cross-platform.

[2] The alternative (if you eventually need rich client side interaction): Github had to implement their design system twice, once on the backend with ViewComponent, and once on the frontend with Primer React, which corresponds. primer.style/ See also reddit thread on it.

Collapse
 
davidteren profile image
David Teren

Nice one Stef. Great to see accompaning spec examples as well.
Going to take some time to go over this proper.