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.
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/controllers/blogs_controller.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/controllers/blog_articles_controller.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://localhost: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/layouts/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://localhost:3000/kontroler/akce či s přídavnými parametryhttp://localhost:3000/kontroler/akce?parametr1=hodnota¶metr2=hodnota. Můžeme třeba chtít, aby se články blogu s id=1 zobrazovaly na http://localhost: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://localhost:3000/katalog/elektro/12/add, bude použit kontrolér katalog a akce add.
Zadáme-li http://localhost:3000/katalog/elektro/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://localhost: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/blogs/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://localhost: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/application_helper.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/guestbook.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 GuestbookController. 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/controllers/guestbook_controller.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/guestbook/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.
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/controllers/guestbook_controller.rb
...
def list
@guestbook_pages, @guestbooks = paginate :guestbook, :per_page => 10, :conditions => 'isnull( parent_id )'
end
...
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/guestbook_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/guestbook/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/guestbook/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í GuestbookController:
app/controllers/guestbook_controller.rb
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
Můžete se podívat do logu log/development.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.