Dynamisches Navigationsmenü mit Highlightning in Ruby on Rails

Und es kam so: Gerade eben aufgestanden und am Gliedersortieren, sprach der Duschkopf zu mir: „Hey, du!“. Obwohl überrascht über diese ungewohnte Intimität des Duzens, war meine Aufmerksamkeit geweckt. „Jetzt mal ehrlich“, so sprachs weiter, „mit dem Symfony Framework hälst Du es gleich wie mit dem Latein: Oftmals angefangen, aber nie ganz warm geworden, gelle.“. Mein Hirn lief schon an der Grenze seiner morgentlichen Leistungsfähigkeit, aber eine Antwort darauf war wahrscheinlich auch gar nicht verlangt. „Du weisst schon, dass PHP eine Templatesprache ist, oder? Wieso hörst du nicht auf mit der Verulkung und versuchst Dich endlich mal am Original?“. Ich entgegnete was in der Richtung, dass die genuinen Römer (TM) schon ziemlich lange die Arena von unten ansehen. „Nein, du Dumpfbacke! Ruby on Rails sollst du versuchen und Freude daran haben! Frohlocken! Beweg deinen alternden Denkapparat du Assemblerheini!“

Natürlich folge ich allen Anweisungen meiner Badezimmerarmaturen, und so habe ich mich unverzüglich an die Arbeit gemacht. Die Standardinstallation hat es natürlich nicht getan (…und Zeit hat man zu viel…), Rails 2 musste es sein, mittlerweile released als Rails 2-2. Auch eine Entwicklungsumgebung musste her, und ich habe mich für Netbeans entschieden, da sie für zwei weitere Projekte (Handygames und JavaFX) eh angesehen werden wollte. Wie das Glück so spielt, hatten auch die Netzbohnen gerade ein grösseres Update auf 6.5 durchgemacht und kleinere Fluchereien mit verwurschtelten Projekten ausgelöst. Naja.

Mit verschiedenen Offline- und Onlinequellen habe ich mich an die Arbeit gemacht, und muss sagen, Ruby on Rails ist, hmm, ungewohnt. Bis jetzt geht es überraschend flüssig, auch wenn ich bei jeder Zeile 4 verschiedene Quellen konsultieren muss um es richtig (TM) zu machen.

Menüstruktur

Das Menüsystem

Als grosse Herausforderung habe ich etwas gewählt, bei dem ich in Symfony immer gescheitert bin. Eine brutal simple Menü-Navigation, in der die angewählten Menüpunkte klar ausgezeichnet werden. Das ist insofern speziell, als dass ein Menü nicht zur Problemdomäne gehört und sich auch DRY-konform ausserhalb den Hauptbereiches abspielen sollte. Ausserdem müssen alle Datensätze die zum Menübaum gehören als statisches Daten geladen werden, entweder als Datenstruktur oder halt in der Datenbank. Zusätzlich wäre es schön gewesen, wenn alles so ziemlich automatisch dargestellt werden würde.

Lösungsansatz

Ich wollte es „The Rails Way“ machen (für Hinweise wie es noch railiger werden kann, wäre ich mehr als dankbar) und habe darum viel herumexperimentiert. Partials, Helpers und Eisenbahnschwellen haben mich nicht wirklich glücklich gemacht. Eine Möglichkeit wäre gewesen, die Menüstruktur als Fixture in die DB zu füllen, aber auch das hat in diesem Fall auch irgendwie gestunken. Erst ein Posting im deutschen Rubyforum hat mich dann auf den richtigen Weg geführt, der auch MVC sehr schön einhält.

C: Der Controller

Da sich die ganze Sache ausserhalb der Problemdomäne und ausserhalb des Bereichs für die Applikation abspielt, hat das Menü keinen eigenen Controller. Viel eher wird sie beim Application-Controller durch einen before_filter eingebunden, der alles schön für die View vorbereitet und das Model bemüht.

Datei Controllers/application.rb:

class ApplicationController < ActionController::Base
  before_filter :init_menus

  helper :all # include all helpers, all the time

  def init_menus
    @main_menu = Menu.get_menu(self.controller_name, self.action_name)
    @sub_menu = Menu.get_menu(self.controller_name, self.action_name, 1)
  end

  protect_from_forgery
 
end

M: Das Datenmodell

Das Datenmodell ist ein Baum, darum verweist jeweils eine parent_menu_id auf den Elterneintrag. Zu jedem Menüeintrag gehören der Cotroller und die Action die bei einem Klick ausgelöst werden sollen:

ruby script/generate model Menu parent_menu_id:integer caption:string menu_controller:string menu_action:string sort:integer

Dann wird das Ganze um die Assoziationen erweitert und verschiedene Methoden angelegt. In Ruby werden statische Methoden mit self.methoden_name bezeichnet. Wer nicht weiss, was statisch Methoden sind, sollte dies sofort nachlesen, zum Beispiel von Mr. Happy Object lernen und verinnerlichen.

Das Model macht folgendes:

get_breadcrumb
DIE zentral ist die Methode. Sie sucht nach allen Menueinträgem mit gewissem Controller und Action oder halt nur dem passenden Controller, und versucht, den Pfad bis zum Hauptmenü (der Wurzel des Menübaumes) zu kreieren.
get_menu
Sucht sich den Level auf dem Breadcrumb und gibt alle Einträge auf demselben Niveau (mit demselben Eltern-Menüpunkt) aus.
get_level
Gibt die Stufe eines Menüpunktes aus

Datei Models/menu.rb:

class Menu < ActiveRecord::Base
  has_many :sub_menus, :class_name => "Menu", :foreign_key => "parent_menu_id", :order => "sort"
  belongs_to :parent_menu, :class_name => "Menu", :foreign_key => "parent_menu_id"
  validates_presence_of :caption
  validates_presence_of :menu_controller
  validates_presence_of :menu_action

  @@breadcrumb = []

  # Gets the submenu, dependend on the main menu
  def self.get_menu controller, action="index", level=0
    Menu.get_breadcrumb(controller, action) if @@breadcrumb.empty?
   
    common_parent = @@breadcrumb[level].parent_menu_id
    Menu.find_all_by_parent_menu_id(common_parent, :order=> "sort")
  end

  # Checks if the menu is in the breadcrumb
  def selected? controller, action="index"
    Menu.get_breadcrumb(controller, action) if @@breadcrumb.empty?
    @@breadcrumb.include? self
  end

  # Returs the level of the menu item
  def get_level
    level=0
    menu_item=self
    while !menu_item.parent_menu.nil?
      menu_item=menu_item.parent_menu
      level+=1
    end
    level
  end

  # readcrumb Navigation: Main > First > Second
  def self.get_breadcrumb controller, action="index"
    # Get all menu items with this controller and action
    start_menu_items=Menu.find_all_by_menu_controller_and_menu_action(controller, action)

    # When the action is not mapped to a menu, try mapping just the controller
    if(start_menu_items.empty?)
      start_menu_items=Menu.find_all_by_menu_controller_and_menu_action(controller, "index")
    end
   
    # Give up, it is legal to not have a controller in the menu
    if(start_menu_items.empty?)
      logger.debug "Controller "+controller+" not found in menu (action was: "+action+")"
      return []
    end

   
    @@breadcrumb = []
    for menu_item in start_menu_items
      while !@@breadcrumb[menu_item.get_level]
       
        @@breadcrumb[menu_item.get_level]=menu_item
        # Break if we are on the main menu level
        break if menu_item.get_level == 0
        menu_item=menu_item.parent_menu
      end
    end
    @@breadcrumb
  end
end

Das Hauptmenü wird direkt in der Migrationsdatei erstellt:Database Migrations/migrate/20081120204044_create_menus:

...
    # Load main menu
    sorter=0
    Menu.create(:caption=> "Home", :menu_controller => "home", :menu_action => "index", :sort => sorter+=1)
    Menu.create(:caption=> "Modules", :menu_controller => "modules", :menu_action => "index", :sort => sorter+=1)
    Menu.create(:caption=> "Courses and Classes", :menu_controller => "classes", :menu_action => "index", :sort => sorter+=1)
    Menu.create(:caption=> "People", :menu_controller => "people", :menu_action => "index", :sort => sorter+=1)
    Menu.create(:caption=> "Basedata", :menu_controller => "basedata", :menu_action => "index", :sort => sorter+=1)
...

Wird eine neue Ressource generiert, so kann sie in der Migrationsdatei direkt in das Menü eingefügt werden. Beispielsweise Datei Database Migrations/migrate/20081120215517_create_departments:

...
    Menu.create(:parent_menu_id => Menu.find_by_caption("Basedata").id, :caption=> "Departments", :menu_controller => "departments", :menu_action => "index", :sort => 1)
...

V: Die View

In der View drin muss nun noch alles schön säuberlich dargestellt werden. Der Menüpfad wird durch eine CSS-Klasse bezeichnet und kann so gestyled werden.

Datei Views/layouts/application.html.erb:

..(Hauptmenü)..
        <ul id="nav">
          <%- for main_menu_item in @main_menu -%>
            <li<%= ' class="navigated"' if main_menu_item.selected?(controller.controller_name, controller.action_name); %>><%= link_to main_menu_item.caption, {:controller => main_menu_item.menu_controller, :action => main_menu_item.menu_action} %></li>
          <%- end -%>
        </ul>
..(Untermenü)..
          <ul>
            <%- for sub_menu_item in @sub_menu -%>


              <li<%= ' class="navigated"' if sub_menu_item.selected?(controller.controller_name, controller.action_name); %>><%= link_to sub_menu_item.caption, {:controller => sub_menu_item.menu_controller, :action => sub_menu_item.menu_action} -%></li>


              <%- if !sub_menu_item.sub_menus.empty? -%>
                <ul>
                  <%- sub_menu_item.sub_menus.each do |sub_sub_menu_item| -%>
                    <li<%= ' class="navigated"' if sub_sub_menu_item.selected?(controller.controller_name, controller.action_name); %>><%= link_to sub_sub_menu_item.caption, {:controller => sub_sub_menu_item.menu_controller, :action => sub_sub_menu_item.menu_action} -%></li>
                  <%-  end -%>
                </ul>
              <%-  end -%>
            <%- end  -%>
          </ul>

Tests

Ok, bei den Tests bin ich ein bisschen überborded. Aber immerhin war ich mal SQE (Software Quality Engineer).

Datei Unit Tests/menu_test.rb:

require 'test_helper'

class MenuTest < ActiveSupport::TestCase
  test "should create a menu" do
    menu = Menu.new(:caption => "New Menu", :menu_controller =>"Test Controller", :menu_action => "Test Action")
    assert menu.save
  end

  test "should require a caption" do
    menu = Menu.new(:menu_controller =>"Test Controller", :menu_action => "Test Action")
    assert !menu.save
  end

  test "should require a menu_controller" do
    menu = Menu.new(:caption => "New Menu", :menu_action => "Test Action")
    assert !menu.save
  end

  test "should require a menu_action" do
    menu = Menu.new(:caption => "New Menu", :menu_controller =>"Test Controller")
    assert !menu.save
  end

  test "should have parent_menu" do
    menu = Menu.find_by_caption("Two One")
    assert_equal("Main Two", menu.parent_menu.caption)
  end

  test "should have sub_menus in correct order" do
    menu = Menu.find_by_caption("One Two")
    assert_equal(3, menu.sub_menus.count)
    assert_equal("One Two One", menu.sub_menus[0].caption)
    assert_equal("One Two Two", menu.sub_menus[1].caption)
    assert_equal("One Two Three", menu.sub_menus[2].caption)
  end

  test "should return empty breadcrumb when controller is not known" do
    breadcrumb = Menu.get_breadcrumb("Hidden Controller", "index")
    assert_equal(0, breadcrumb.count)
  end

  test "should look for index action in breadcrumb if action is not known" do
    breadcrumb = Menu.get_breadcrumb("group", "dancewith")
    assert_equal(2, breadcrumb.count)
    assert_equal("Main Two", breadcrumb[0].caption)
    assert_equal("Two One", breadcrumb[1].caption)
  end

  test "should return a nice breadcrumb (for feeding the pidgeons)" do
    breadcrumb = Menu.get_breadcrumb("revisions", "new")
    assert_equal(3, breadcrumb.count)
    assert_equal("Main One", breadcrumb[0].caption)
    assert_equal("One Two", breadcrumb[1].caption)
    assert_equal("One Two One", breadcrumb[2].caption)
  end

  test "should report its correct level" do
    menu = Menu.find_by_caption("Main One")
    assert_equal(0, menu.get_level)
    menu = Menu.find_by_caption("Two One")
    assert_equal(1, menu.get_level)
    menu = Menu.find_by_caption("One Two One")
    assert_equal(2, menu.get_level)

  end

  test "should return a main navigation menu" do
    # Main Menu
    Menu.get_breadcrumb("departments")
    navigation = Menu.get_menu("departments")
    assert_equal(2, navigation.count)
    assert_equal("Main One", navigation[0].caption)
    assert_equal("Main Two", navigation[1].caption)
  end

  test "should return a sub navigation menu" do
    # Main Menu
    Menu.get_breadcrumb("revisions", "edit")
    navigation = Menu.get_menu("revisions", "edit", 1)
    assert_equal(2, navigation.count)
    assert_equal("One One", navigation[0].caption)
    assert_equal("One Two", navigation[1].caption)
  end

  test "should be selected if in breadcrumb" do
    Menu.get_breadcrumb("group")
    assert Menu.find_by_caption("Main Two").selected?("group")
    assert !Menu.find_by_caption("Main One").selected?("group")
    Menu.get_breadcrumb("revisions","new")
    assert Menu.find_by_caption("One Two One").selected?("revisions","new")
  end
end

Und die Fixtures in Test Fixtures/menus.rb:

# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html

MainOne:
  parent_menu_id:
  caption: Main One
  menu_controller: departments
  menu_action: index
  sort: 1

MainTwo:
  parent_menu_id:
  caption: Main Two
  menu_controller: people
  menu_action: index
  sort: 2

OneOne:
  parent_menu: MainOne
  caption: One One
  menu_controller: departments
  menu_action: index
  sort: 1

OneTwo:
  parent_menu: MainOne
  caption: One Two
  menu_controller: revisions
  menu_action: index
  sort: 2

OneTwoTwo:
  parent_menu: OneTwo
  caption: One Two Two
  menu_controller: revisions
  menu_action: edit
  sort: 2

OneTwoOne:
  parent_menu: OneTwo
  caption: One Two One
  menu_controller: revisions
  menu_action: new
  sort: 1

OneTwoThree:
  parent_menu: OneTwo
  caption: One Two Three
  menu_controller: departments
  menu_action: index
  sort: 3

TwoOne:
  parent_menu: MainTwo
  caption: Two One
  menu_controller: group
  menu_action: index
  sort: 2

Und zum Schluss noch dies

Noch ein kleiner Gag für Faule: Folgende Zeile setzt den Titel automatysch und dynamisch:

<title>lomas: Learning Object Management Application System - <%= controller.controller_name.capitalize %>: <%= controller.action_name.capitalize %></title>

Bei Railscasts gibt es auch einen Screencast, der noch ein paar weitere Möglichkeiten zeigt.

2 Gedanken zu “Dynamisches Navigationsmenü mit Highlightning in Ruby on Rails

  1. Na dann Willkommen in der wunderbaren Welt von RoR 🙂
    habe auch vor kurzem damit angefangen und mir hat folgendes OpenBook sehr weitergeholfen: http://examples.oreilly.de/openbooks/pdf_rubyonrailsbasger.pdf

    Ein Problem das du vl auch schon agetroffen hast, sind die wenigen Hoster in der Schweiz… Wenn du einen guten findest, würd ich mich über eine kurze Info freuen 🙂

    grz

    PS. Der Symfonie-Link hat ein .org am Ende, kein .com 😛

  2. @Compr00t Hey, genialer Link, danke. In Rails 2.0 hat sich einiges geändert, darum ist es momentan schwer, richtige, funzende Doku zu finden…

    Das mit den Hostern hab ich mal nach hinten geschoben. Ich werde Dich auf dem Laufenden halten und wäre ebenfalls froh um Deine Erfahrungen…

    Symfony ist korrigiert… Messiiii…

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.