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:
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:
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
indexaction for details
"A vehicle is enhanced with many features. Can be translated as an
indexaction for features.
"A vehicle is presented visually using many pictures". Can be translated as an
indexaction for pictures
"A vehicle is parked at a location". Can be translated as a
showaction 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
scope are similar but have some slight nuances that make them better for different scenarios. If we were to use
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
A path helper that also has the word
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
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?
vehicle is composed of many resources such as
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
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
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.
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.
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!