Ruby on Rails: Dokončení blogu

6. 1. 2006
Doba čtení: 11 minut

Sdílet

Dnes budeme dokončovat, uhlazovat, upravovat a vylepšovat. Dokončíme blog z minulého dílu, ukážeme si, jak udělat url hezčí a nakonec upravíme guestbook do strukturované podoby.

K administrační části blogů, kterou jsme vytvořili v předchozím díle, je nutné přidat zobrazovací část na stránkách. Jak se liší administrační část od zobrazovací? V administrační části jsou články blogů editovatelné a vypisují se jen blogy náležející přihlášenému uživateli, kdežto v zobrazovací části se vypisují blogy všech uživatelů, ale jejich články nelze editovat.

Bude to fungovat tak, že po kliknutí v menu na „Blogy“ se ukáže seznam blogů a po kliknutí na konkrétní blog se zobrazí všechny články tohoto blogu.

Blogy - list
Blogy - clanky

O administrační a zobrazovací část se budou starat dva různé kontroléry. K vytvoření kontrolérů zobrazovací části nepoužijeme scaffold generátor, poněvadž je jednodušší zkopírovat kontroléry z administrační části. Ty se nalézají v podadresáři admin. Zkopírujeme je o úroveň výš, přičemž smažeme případné kontroléry, které tam zbyly z historických důvodů. Totéž provedeme se šablonami.

mig> rm -r app/controllers/blog* app/views/blog*
mig> cp app/controllers/admin/blogs_controller.rb app/controllers
mig> cp app/controllers/admin/blog_articles_controller.rb app/controllers
mig> cp -r app/views/admin/blogs app/views
mig> cp -r app/views/admin/blog_articles app/views 

V kontrolérech změníme layout admin na rolldance; layout totiž obsahuje menu (zatím se od sebe oba layouty víceméně neliší). Zrušíme předpony Admin:: v názvech kontrolérů a smažeme v kontrolérech všechny akce kromě index a list, které ještě upravíme. Výsledek by měl vypadat takto:

app/controller­s/blogs_contro­ller.rb

class BlogsController < ApplicationController

        layout  'rolldance'

        def index
                list
                render :action => 'list'
        end

        def list
                @blog_pages, @blogs = paginate :blogs, :per_page => 10
                @blogs = Blog.find( :all )
        end

end 

app/controller­s/blog_articles_con­troller.rb

class BlogArticlesController < ApplicationController

        layout  'rolldance'

        def index
                list
                render :action => 'list'
        end

        def list
                @blog_article_pages, @blog_articles = paginate :blog_articles, :per_page => 10
                if params[:id]
                        session[:blog_id] = params[:id]
                end

                @blog = Blog.find( session[:blog_id] )
                @blog_articles = @blog.find_in_blog_articles( :all )
        end

end 

Zkuste http://localhos­t:3000/blogs. Odkaz „Blogy“ v menu ukazuje chybně na kontrolér blog, který se ale nyní jmenuje blogs, upravte proto menu v souboru app/views/lay­outs/rolldance­.rhtml. Jinak by mělo vše fungovat.

URL mapy

Nevýhodou výše uvedeného postupu je, že v kontroléru blog_articles zaznamenáváme id aktuálního blogu do session. Proč, když lze parametry předávat v url? Takový postup je navíc obecně výhodnější kvůli vyhledávačům.

URL nemusí být pouze v defaultním formátu http://localhos­t:3000/kontro­ler/akce či s přídavnými parametryhttp://lo­calhost:3000/kon­troler/akce?pa­rametr1=hodno­ta&parametr2=hod­nota. Můžeme třeba chtít, aby se články blogu s id=1 zobrazovaly na http://localhos­t:3000/blogy/1, „1“ pak nemůže být vyhodnoceno jako název akce, nýbrž jako id blogu. Řešením je mapa masek url na kontroléry, akce a parametry. Definice se provádí v souboru config/routes.rb. Soubor defaultně obsahuje základní mapování:

map.connect ':controller/:action/:id' 

Této masce odpovídají všechny url, na něž jsme byli zvyklí. Přidáme do mapy nové cesty, a to tak, že je předřadíme této defaultní, protože na pořadí záleží (je použita první cesta, která odpovídá url, neodpovídá-li žádná, vyvolána je výjimka).

map.connect 'blogy', :controller=>'blogs'
map.connect 'blogy/:id', :controller=>'blog_articles'
map.connect ':controller/:action/:id' 

První cesta říká: Bude-li url začínat a končit „blogy“, předá se řízení kontroléru blogs (a akci index, nespecifikujeme-li jinak). Druhá cesta definuje, do jaké proměnné bude umístěna hodnota za lomítkem (v kontroléru pak hodnotu této proměnné získáme jako params[:id]), akce je opět defaultní, index.

Pro úplnost uvádím následující mapu (obecně):

map.connect 'katalog/:category/:item_id/:action/:lang',
        :controller=>'catalog',
        :defaults=> {
                :action => 'show' ,
                :category=>'elektro'
        },
        :requirements=>{
                :category=>/elektro|foto|jine/
        },
        :item_id=>nil, # optional
        :lang=>'cz'
map.connect 'catalogue/:lang', :controller=>'catalog', :lang=>'en'
map.connect 'katalog/:lang', :controller=>'catalog', :lang=>'cz' 

Zadáme-li http://localhos­t:3000/katalog/e­lektro/12/add, bude použit kontrolér katalog a akce add.

Zadáme-li http://localhos­t:3000/katalog/e­lektro/12, bude použit kontrolér katalog a akce show, neboť akce není v url definována, ale je definována její defaultní hodnota v asociovaném poli :defaults.

Proměnná :category je navíc otestována, zda odpovídá regulárnímu výrazu (může nabývat hodnot buď elektro, foto nebo jine), neodpovídá-li, pak se zkusí následující cesta (neodpovídá-li žádná z cest, vyvolá se výjimka).

Ostatní parametry map.connect fungují jako přídavné proměnné. Tyto proměnné se chovají stejně jako defaultní, ale s tím rozdílem, že nemusejí být uvedeny v masce url. Alespoň tak jsem to pochopil z manuálu. Přesto, když je do masky nedám, cesta nebude vyhodnocena jako správná. Díky :lang lze navíc v kontroléru určit, jakému jazyku odpovídá url a podle toho nastavit jazyk stránek (určitě existují i jiné možnosti).

Kontrolér BlogArticles vypadá po úpravě takto:

class BlogArticlesController < ApplicationController

        layout  'rolldance'

        def index
                list
                render :action => 'list'
        end

        def list
                @blog_article_pages, @blog_articles = paginate :blog_articles, :per_page => 10, :conditions => [ 'blog_id=?', params[:id] ]
                if @blog_articles.empty?
                        @blog = Blog.find( params[:id] )
                else
                        @blog = @blog_articles.first.blog
                end

        end

end 

K jakým změnám došlo? Kontrolér již nepoužívá session. Objekt blogu, z něhož ve výpisu článků určujeme název blogu, je získáván buď pomocí prvního článku (je-li v blogu nějaký) nebo rovnou pomocí modelu Blog. Neměl by v tom být rozdíl, uvádím pro příklad.

Pretty URL

Jsme nároční, kvůli vyhledávačům bychom chtěli do url „nacpat“ i název blogu http://localhos­t:3000/blogy/2-muj-prvni-blog. Vzhledem k tomu, že Blog.find() nejprve převádí řetězce na čísla, přičemž z „2-muj-prvni-blog“.to_i vznikne číslo 2, stačí upravit odkaz v app/views/blog­s/list.rhtml:

<td><%= link_to blog.name, :controller => 'blog_articles', :id => blog.id %></td> 
<td><%= link_to blog.name, :controller => 'blog_articles', :id => blog.id.to_s + "-" + blog.name.gsub(/[^\w]/,'-') %></td> 

Kdyby ale název blogu obsahoval háčky a čárky, dostali bychom http://localhos­t:3000/blogy/3-m-j-prvn–blog. Je nutné názvy nejprve odčeštit. To vyžaduje mírně složitější kód, který však nechceme v šabloně. Napíšeme proto obecný helper (předpokládá, že pracujete v UTF-8, což je v Rails defaultně; bohužel jsem nepřišel na lepší způsob odčeštění řetězce, než ho zkonvertovat do iso8859–2 a pak převést pomocí metody tr).

app/helpers/ap­plication_hel­per.rb

# Methods added to this helper will be available to all templates in the application.
module ApplicationHelper

    require 'iconv'

    def named_id( id, name )
        begin

            name = Iconv.iconv( 'iso8859-2', 'utf-8', ( id.to_s + '-' + name ) ).first
            name.tr!( 'áčďěéíňóřšťúůýž', 'acdeeinorstuuyz' )
            name.tr!( 'ÁČĎĚÉÍŇÓŘŠŤÚŮÝŽ', 'ACDEEINORSTUUYZ' )
        rescue
        end
        name.gsub( /[^\w]/, '-' )
    end

end 

V šabloně helper použijeme takto:

<td><%= link_to blog.name, :controller => 'blog_articles', :id => named_id( blog.id, blog.name ) %></td> 

Kniha hostů

V další části zapomeňme na blogy a pojďme se zabývat knihou hostů. Ta zatím funguje jako jednoduchý scaffold. Měla by však být strukturovaná, aby šlo odpovědět na záznam a odpovědět na odpověď.

Stávající tabulku jsem upravil: přidal jsem parent_id, cizí klíč odkazující do té samé tabulky, a sloupec „date“ jsem změnil na „created_on“ (proč ne, že…)

create table guestbooks (
    id int unsigned auto_increment,
    parent_id int unsigned,
    nick varchar(30),
    created_on datetime,
    message text,
    constraint fk_guestbook foreign key (parent_id) references guestbooks(id),
    primary key (id)
) 

V modelu Guestbook využijeme ActiveRecord, konkrétně Acts As Tree. Díky tomu se tabulka začne chovat jako objektový strom. Každý uzel bude mít metodu children, vracející své děti či umožňující další děti vytvořit. Děti budou vypisovány v pořadí podle created_on.

app/models/gu­estbook.rb

class Guestbook < ActiveRecord::Base

    acts_as_tree :order=>'created_on'

end 

Místo odkazů edit, show a destroy bude u každého záznamu knihy hostů odkaz „odpovědět“, který spustí akci „reply“ kontroléru GuestbookContro­ller. Objeví se formulář jako při zadávání nové položky knihy hostů, jehož odesláním se spustí akce create_reply, která na rozdíl od běžné akce create přidá odpověď jako dítě ke konkrétnímu rodiči. Uvádím i metodu list, v níž je chyba (původně tam bylo paginate :guestbooks).

app/controller­s/guestbook_con­troller.rb

...
        def list
                @guestbook_pages, @guestbooks = paginate :guestbook, :per_page => 10
        end

        def reply
                @reply_to_guestbook = Guestbook.find(params[:id])
                @guestbook = Guestbook.new
        end

        def create_reply
                @reply_to_guestbook = Guestbook.find( params[:reply_to_id] )
                @guestbook = @reply_to_guestbook.children.create( params[:guestbook] )
                if @reply_to_guestbook.save
                        flash[:notice] = 'Guestbook was successfully created.'
                        redirect_to :action => 'list'
                else

                        render :action => 'reply'
                end
        end
... 

app/views/gues­tbook/list.rhtml

...
<%= link_to 'Odpovedet', :action => 'reply', :id => guestbook %>

... 

Zbývá vytvořit šablonu pro akci reply, která bude kombinací šablon show a edit; nahoře zobrazí záznam, na nějž odpovídáme a pod ním formulář.

<h1>Odpoved na</h1>

<% for column in Guestbook.content_columns %>
<p>
  <b><%= column.human_name %>:</b> <%=h @reply_to_guestbook.send(column.name) %>

</p>
<% end %>

<hr />

<%= start_form_tag :action => 'create_reply', :reply_to_id => @reply_to_guestbook %>

  <%= render :partial => 'form' %>
  <%= submit_tag 'Odpovedet' %>
<%= end_form_tag %>

<%= link_to 'Back', :action => 'list' %> 

Zkusil jsem přidat několik záznamů a na pár odpovědět. Takto vypadal výpis na stránkách, následuje odpovídající výpis z databáze.

Guestbook-list
mysql> select * from guestbooks;
+----+-----------+-------------------------+---------------------+------------------------------------------------+
| id | parent_id | nick                    | created_on          | message                                        |
+----+-----------+-------------------------+---------------------+------------------------------------------------+
|  6 |      NULL | prvni                   | 2006-01-05 07:38:00 | prvni zaznam                                   |
|  7 |      NULL | druhy                   | 2006-01-05 07:38:00 | druhy zaznam                                   |
|  8 |         6 | prvni-odpoved1          | 2006-01-05 07:38:00 | prvni odpoved na prvni zaznam                  |
|  9 |         8 | prvni-odpoved1-odpoved1 | 2006-01-05 07:38:00 | prvni odpoved na prvni odpoved prvniho zaznamu |
| 10 |         8 | prvni-odpoved1-odpoved2 | 2006-01-05 07:39:00 | druha odpoved na prvni odpoved prvniho zaznamu |
+----+-----------+-------------------------+---------------------+------------------------------------------------+
5 rows in set (0,00 sec) 

Strom se nám sice v databázi vytváří, nikoli však na stránkách. Co s tím? Prvně upravme kontrolér a listujme jen kořenové položky stromu, tedy ty s nenastaveným parent_id.

app/controller­s/guestbook_con­troller.rb

...
def list
    @guestbook_pages, @guestbooks = paginate :guestbook, :per_page => 10, :conditions => 'isnull( parent_id )'
end
... 
Guestbook-list1

Teď vypíšeme k těmto kořenovým položkám jejich děti. Budou odsazeny zleva podle úrovně zanoření. Napíšeme k tomu účelu helper, který nám vrátí pole; položky pole budou obsahovat posloupnost záznamů knihy hostů, jako by je vrátila metoda Guestbook.find(), avšak v jiném pořadí (podle rodičů a dětí), navíc budou rozšířeny o hodnotu :level – úroveň zanoření.

app/helpers/gu­estbook_helper­.rb

module GuestbookHelper

        def guestbook_table( nodes )
                table = []
                nodes.each do |node|
                recursive_list_node( node, table )
                end
                table
        end

        def recursive_list_node( node, table, level=0 )
                # pridam do pole table novy radek row, ktery sam o sobe bude asociovanym polem
                table << row = {}
                # row nyni obsahuje vsechny atributy uzlu (nick, message)
                row.update( node.attributes )
                # krome atributu uzlu pridam level
                row[:level] = level
                # a totez pro deti
                node.children.each do |child|
                recursive_list_node( child, table, level + 1 )
                end
        end

end 

Do šablony list přidáme debug, abychom viděli, co je výstupem helperu.

app/views/gues­tbook/list.rhtml

...
<%= debug guestbook_table( @guestbooks ) %>
... 

Výstup:

-
  created_on: 2006-01-05 07:38:00 +01:00
  message: prvni zaznam
  nick: prvni
  id: 6
  :level: 0
  parent_id:
-
  created_on: 2006-01-05 07:38:00 +01:00
  message: prvni odpoved na prvni zaznam
  nick: prvni-odpoved1
  id: 8
  :level: 1
  parent_id: 6
-
  created_on: 2006-01-05 07:38:00 +01:00
  message: prvni odpoved na prvni odpoved prvniho zaznamu
  nick: prvni-odpoved1-odpoved1
  id: 9
  :level: 2
  parent_id: 8
-
  created_on: 2006-01-05 07:39:00 +01:00
  message: druha odpoved na prvni odpoved prvniho zaznamu
  nick: prvni-odpoved1-odpoved2
  id: 10
  :level: 2
  parent_id: 8
-
  created_on: 2006-01-05 07:38:00 +01:00
  message: druhy zaznam
  nick: druhy
  id: 7
  :level: 0
  parent_id: 

Vypadá to dobře, šablonu list přepište následujícím kódem:

app/views/gues­tbook/list.rhtml

<h1>Listing guestbooks</h1>

<% guestbook_table( @guestbooks ).each do |row| %>

    <div style='padding-left: <%= row[:level]*50 %>px' >
        <strong><%= row['nick'] %> </strong> <%= link_to 'Odpovedet', :action => 'reply', :id => row['id'] %> <br />

        <%= row['message'] %>
    </div>
<% end %>

<br />

<%= link_to 'Novy zaznam', :action => 'new' %> 

Raději uvedu i kompletní GuestbookContro­ller:

app/controller­s/guestbook_con­troller.rb

bitcoin_skoleni

class GuestbookController < ApplicationController

        layout 'rolldance'

        def index
                list
                render :action => 'list'
        end

        def list
                # only root nodes
                @guestbook_pages, @guestbooks = paginate :guestbook, :per_page => 10, :conditions => 'isnull( parent_id )'
        end

        def new
                @guestbook = Guestbook.new
        end

        def create
                @guestbook = Guestbook.new(params[:guestbook])
                if @guestbook.save
                        flash[:notice] = 'Guestbook was successfully created.'
                        redirect_to :action => 'list'
                else
                        render :action => 'new'
                end

        end

        def reply
                @reply_to_guestbook = Guestbook.find(params[:id])
                @guestbook = Guestbook.new
        end

        def create_reply
                @reply_to_guestbook = Guestbook.find( params[:reply_to_id] )
                @guestbook = @reply_to_guestbook.children.create( params[:guestbook] )
                if @reply_to_guestbook.save
                        flash[:notice] = 'Guestbook was successfully created.'
                        redirect_to :action => 'list'
                else

                        render :action => 'reply'
                end
        end

        def update
                @guestbook = Guestbook.find(params[:id])
                if @guestbook.update_attributes(params[:guestbook])
                        flash[:notice] = 'Guestbook was successfully updated.'
                        redirect_to :action => 'show', :id => @guestbook
                else

                        render :action => 'edit'
                end
        end

        def destroy
                Guestbook.find(params[:id]).destroy
                redirect_to :action => 'list'
        end

end 
Guestbook-list2

Můžete se podívat do logu log/developmen­t.log, kde jsou zaznamenány databázové dotazy, že to „není zase tak strašné“. Určitě by šlo celou tabulku přednačíst jediným selectem a strom vyrobit až v paměti nebo, jako popíšu příště, celý výstup kešovat.

Celý web tak, jak by měl aktuálně vypadat, si můžete stáhnout v jednom tarballu.

Seriál: Ruby on Rails