The Navigation Object Pattern in Rails
Most Rails apps end up with navigation logic scattered across layouts, helpers, and partials. Conditionals multiply as you add roles — admin sees these links, students see those, interpreters see something else. Before long the layout is unreadable.
A cleaner approach: a plain Ruby object that owns the entire navigation structure and returns simple data your views can render without thinking.
The Navigation Class
app/lib/navigation.rb:
class Navigation
include Rails.application.routes.url_helpers
def initialize(request, user_type = nil)
@request = request
@user_type = user_type&.to_sym
end
def items
case @user_type
when :admin then admin_items
when :interpreter then interpreter_items
when :student then student_items
else default_items
end
end
private
def admin_items
[
{ id: :dashboard, label: "Dashboard", icon: "bi-house",
path: root_path, active: active?(root_path) },
{ id: :users, label: "Users", icon: "bi-people",
path: users_path, active: active?(users_path) },
{ id: :reports, label: "Reports", icon: "bi-bar-chart",
path: "#", active: dropdown_active?(report_children),
children: report_children }
]
end
def report_children
[
{ id: :monthly, label: "Monthly", icon: "bi-calendar",
path: reports_path(period: :monthly),
active: active?(reports_path(period: :monthly)) },
{ id: :annual, label: "Annual", icon: "bi-calendar-range",
path: reports_path(period: :annual),
active: active?(reports_path(period: :annual)) }
]
end
def interpreter_items
[
{ id: :dashboard, label: "Dashboard", icon: "bi-house",
path: root_path, active: active?(root_path) },
{ id: :assignments, label: "Assignments", icon: "bi-journal-check",
path: assignments_path, active: active?(assignments_path) }
]
end
def student_items
# ...
end
def default_items
# ...
end
def active?(path)
resolved = path.is_a?(Proc) ? path.call : path
@request.path == resolved
end
def dropdown_active?(children)
children.any? { |child| active?(child[:path]) }
end
end
Each role gets its own method returning an array of hashes. The hashes are just data — no HTML, no rendering decisions. The active? method compares the current request path against each item, and dropdown_active? checks if any child in a dropdown is current.
Rendering in the Layout
Instantiate it with the current request and user type:
<% navigation = Navigation.new(request, current_user.role) %>
<nav>
<ul>
<% navigation.items.each do |item| %>
<%= render partial: "shared/nav_item", locals: { item: item } %>
<% end %>
</ul>
</nav>
The partial handles the two cases — simple link or dropdown:
app/views/shared/_nav_item.html.erb:
<% if item[:children] %>
<li class="dropdown<%= " active" if item[:active] %>">
<a href="#" class="dropdown-toggle" data-bs-toggle="dropdown">
<i class="<%= item[:icon] %>"></i> <%= item[:label] %>
</a>
<ul class="dropdown-menu">
<% item[:children].each do |child| %>
<li>
<a href="<%= child[:path] %>"
class="<%= "active" if child[:active] %>">
<i class="<%= child[:icon] %>"></i> <%= child[:label] %>
</a>
</li>
<% end %>
</ul>
</li>
<% else %>
<li class="<%= "active" if item[:active] %>">
<a href="<%= item[:path] %>">
<i class="<%= item[:icon] %>"></i> <%= item[:label] %>
</a>
</li>
<% end %>
Why This Works
The navigation class is testable — pass in a mock request and assert the returned array. Adding a new role is one method. Reordering items is moving lines in an array. The views just iterate data and never decide who sees what.
It also plays well with icon libraries since the icon value is just a CSS class string. Switch from Bootstrap Icons to Tabler Icons by changing "bi-house" to "ti-home" in one place.