← All posts

The Navigation Object Pattern in Rails

March 15, 2023 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.

Let’s work together! Tell Me About Your Project