Advanced Layout Rendering in Rails
Part 3: Making shared, data-rich layouts for subsections of your apps
To finish this series we'll finally tackle this part of the car-sharing app we've been creating in this Action View Layouts series.
To recap, in part 2 I described my process to uncover what the resources these screens show are and how these all fall under the namespace of a Vehicle
(check out the article here if you need a refresher). This makes it easy to name and create the controllers that will handle these views:
Vehicle::DetailsController
Vehicle::FeaturesController
Vehicle::PicturesController
Vehicle::LocationsController
In this article, we'll explore how to design routes and leverage inheritance and implicit layout rendering to make this design a breeze.
Designing expressive routes
To access these controllers we will need to add our resources to the router. I find it helpful to visualise the actual URLs first before jumping in. Here's what I imagine:
vehicles/:vehicle_id/details
vehicles/:vehicle_id/features
vehicles/:vehicle_id/pictures
vehicles/:vehicle_id/location
👀
See anything special? All of these URLs describe plural resources except for one.
Even if these aren't Active Record models with backing database tables, you can still think of them as entities having relationships between them.
"A vehicle has many details". Can be translated as an
index
action for details"A vehicle is enhanced with many features. Can be translated as an
index
action for features."A vehicle is presented visually using many pictures". Can be translated as an
index
action for pictures"A vehicle is parked at a location". Can be translated as a
show
action for its location
Resources (plural) vs Resource (singular)
Rails' router allows you to declare singular or plural versions of a resource and this will take on a different meaning and create different URLs for your application. Let's take the Vehicles::Location
controller as an example. If we were to use the plural version:
Rails.routes.draw do
# ...
resources :vehicles, only: %w(index) do
resources :locations, only: %w(show) # 👀 resourceS: plural
end
end
The output of this will be:
...
vehicle_location_path
GET /vehicles/:vehicle_id/location/:id
location#show
...
But what if your vehicle's location doesn't live in another model and table? What if it's just an attribute of the vehicle itself? You won't have an ID to go look for it.
This is what the singular resource
method is for:
Rails.routes.draw do
# ...
resources :vehicles, only: :index do
resource :locations, only: :show # 👀 resource: singular!
end
end
Now the output is just as we described above
vehicle_location_path
GET /vehicles/:vehicle_id/location ✨ singular, with not id param
locations#show
The documentation for the resource
method explains it very well:
Sometimes, you have a resource that clients always look up without referencing an ID. A common example, /profile always shows the profile of the currently logged in user. In this case, you can use a singular resource to map /profile (rather than /profile/:id) to the show action [...]
Scoping controllers to modules
We've solved one piece but we still have a problem. Did you notice the controller names in code snippets?
vehicle_location_path
GET /vehicles/:vehicle_id/location
locations#show ⬅️ this are the controller#action names
The controller is just locations
instead of vehicles::locations
. This can be an issue if there are other "locations" in your app. Imagine the user can have multiple stored locations where they usually search cars from. Like, from home and work. Both a "vehicle's location" and "a user's usual locations" need a controller with the word "locations" in them. This is why the router provides namespacing and scoping methods.
The most immediate benefit of using these methods is to allow your controllers to be nested within a module and to live in a different folder:
app/
controllers/
vehicles_controller.rb
vehicles
locations_controller.rb
...
# app/controllers/vehicles/locations_controller.rb
module Vehicles
class LocationsController < ApplicationController
end
end
namespace
and scope
are similar but have some slight nuances that make them better for different scenarios. If we were to use namespace
:
Rails.application.routes.draw do
resources :vehicles, only: :index do
namespace :vehicles do
resource :location, only: :show
end
end
end
And look at the outcome:
vehicle_vehicles_location_path GET /vehicles/:vehicle_id/vehicles/location(.:format)
vehicles/locations#show
You can see that we got (spoiler, it's sad):
A URL that has the word
vehicles
two timesA path helper that also has the word
vehicle
twice.
If we want to design beautiful and expressive routes, this is not the way. So let's try scope
. This method receives several arguments but we care only about module
today which will scope or nest our controllers under a different Ruby module:
Rails.application.routes.draw do
resources :vehicles, only: :index do
scope module: :vehicles do
resource :location, only: :show
end
end
end
vehicle_location_path
GET /vehicles/:vehicle_id/location(.:format)
vehicles/locations#show
Now we got (spoiler: it's good!):
A simple and succinct URL 🎉
A very expressive path helper 🎉
A locations controller nested under the
vehicles
namespace 🎉
Why care so much?
We've gone on and on about routes and diving deep into this. Who cares if paths are simple or if things are nested? Why is this important? Because taking the time to design these properly has unlocked all of the pieces to create the interface we set out to create.
Putting it all together
So how do we make it so all of the pages for the vehicle share the same information at the top (red square), but render different content at the bottom (green square)? Does this ring a bell?
A vehicle
is composed of many resources such as location
or pictures
, it's the parent of all of these resources...
Its children share the same layout.
... see where I'm getting at here? If you've followed the series you'll probably start to see that we have seen this problem before. If there is a parent controller that can dictate how its children behave, we should create one:
module Vehicles
class MainController < ApplicationContrller
end
end
Saying it out loud helps bring it home:
We've created the main controller for the vehicle section of our app.
Because all of its children will use the same layout, we should create one for this main controller:
app/
views/
layouts/
application.html.erb
vehicles
main.html.erb ✅
We now make it so all child controllers inherit from this Main
controller:
class Vehicles::DetailsController < Vehicles::MainController
def index
@vehicle = Vehicle.find(params[:vehicle_id])
@details = @vehicle.details
end
end
class Vehicles::FeaturesController < Vehicles::MainController
def index
@vehicle = Vehicle.find(params[:vehicle_id])
@features = @vehicle.features
end
end
class Vehicles::ImagesController < Vehicles::MainController
def index
@vehicle = Vehicle.find(params[:vehicle_id])
@images = @vehicle.images
end
end
All of these controllers need a @vehicle
to render the top section of our layout. And because our routes are beautifully designed and conventional, all of them share the same param vehicle_id
which means we can pull this out to the parent controller as a callback.
module Vehicles
class MainController < ApplicationContrller
before_action :set_vehicle
private
def set_vehicle
@vehicle = Vehicle.find(params[:vehicle_id])
end
end
end
Enabling change
But what if this design didn't work well with your users and they are getting confused about tabs? Maybe you design an experience where there aren't tabs anymore and all information is stacked in one single view.
Does this mean you have to go back to a single controller with a bunch of instance variables?
No (if you use Turbo)
Since we have built a strong foundation that separates every resource the vehicle has, it's a matter of removing the tabs and making a template that leverages turbo frames. Let's give it a quick go.
For a Vehicle::Details#index
page that will stack all of the info in our tabs into a single, scrollable view you could do something like this:
<!-- app/views/vehicles/details -->
<section>
<h2>Details</h2>
<%= render "details", locals: @details" %>
<%= turbo_frame_tag "location",
src: vehicle_location_path(@vehicle),
loading: :lazy %>
<%= turbo_frame_tag "features",
src: vehicle_features_path(@vehicle),
loading: :lazy %>
<%= turbo_frame_tag "pictures",
src: vehicle_pictures_path(@vehicle),
loading: :lazy %>
</section>
Notice how the frame tags now point to the controllers we had created and how we can even leverage the lazy loading feature of Turbo Frames to optimise the rendering of this view.
Conclusion
To recap, we have learnt:
How implicit rendering works in Action View in Rails
How to create layouts for specific controllers
How to create layouts that multiple controllers will share through the default lookup chain Action View goes through
How to design expressive routes that...
Allow you to use all the concepts above to create complex views
Can you think of a place in your app where you could apply this? Let me know!
See you in the next one.
Off the Rails
I've been loving writing these articles and seeing the reception from you all. Thanks for liking, reposting, retweeting and sharing this.
If you're a subscriber to the newsletter, I'd love for you to reply to this article and let me know if you liked it and maybe tell me what you'd like to hear about next.
If you haven't subscribed though, click here and subscribe!