Dynamiczne ładowanie plików z formularza w Ruby on Rails

23 marca 2008, 00:38:32

Poziom: 0 | Kategoria: Komputerowo-internetowo, Ruby, Ruby on Rails, Techblog.

Wstęp

Wyobraźmy sobie, że chcemy utworzyć bazę ogłoszeń, do których chcemy w łatwy i przyjemny sposób dodawać zdjęcia. W tej notce pokażę jak w dosyć prosty sposób wykorzystać Railsy do stworzenia prostego panelu do zarządzania takimi ogłoszeniami. Stworzymy formularz dodawania ogłoszeń, w którym oprócz możliwości dodawania ogłoszeń umieścimy prosty formularz, który umożliwi załadowanie zdjęcia na serwer i podpięcie go pod dodawane ogłoszenie. Oczywiście udostępnimy możliwość wyboru zdjęć już obecnych na serwerze, dla uproszczenia zakładamy, że zdjęcie może należeć tylko do jednego ogłoszenia.

Wymagania

Wszystko, czego potrzebujemy to:

Zaczynamy

Zacznijmy od stworzenia nowego projektu. Nie będę skupiał się na podstawach, na potrzeby tego opisu wykorzystam domyślną konfigurację railsów z bazą SQLite. W linii poleceń tworzymy nowy projekt:

$ rails demo
$ cd demo

Od razu stworzymy bazę danych, aby potem nie trzeba było do tego wracać:

$ rake db:create

Mając przygotowany projekt, zaopatrzmy się w odpowiednie pluginy. W tym celu odpalamy następujące polecenia:

$ script/plugin install http://svn.techno-weenie.net/projects/plugins/attachment_fu/
$ script/plugin install http://responds-to-parent.googlecode.com/svn/trunk

Środowisko mamy już przygotowane. Pora na utworzenie modeli. Ograniczymy się do najbardziej podstawowych rzeczy. Utworzymy model Advertisement, który będzie miał tytuł oraz opis. Potrzebny będzie również model Photo, który będzie miał atrybuty wymagane przez plugin attachment_fu. Wklepujemy zatem w konsoli:

$ script/generate model Advertisement name:string description:text

W przypadku modelu Photo nie będziemy podawać żadnych parametrów dla generatora:

$ script/generate model Photo

Po wygenerowaniu modeli, zanim zmigrujemy bazę danych, wyedytujemy plik migracji db/migrate/002_create_photos.rb, wypełniając go jak poniżej:

class CreatePhotos < ActiveRecord::Migration
  def self.up
    create_table :photos do |t|
      t.column :parent_id,  :integer
      t.column :content_type, :string
      t.column :filename, :string    
      t.column :thumbnail, :string 
      t.column :size, :integer
      t.column :width, :integer
      t.column :height, :integer
      t.column :advertisement_id, :integer
    end
  end

  def self.down
    drop_table :photos
  end
end

W tej chwili możemy już zmigrować bazę danych

$ rake db:migrate

Konfiguracja modeli

Pora na skonfigurowanie modeli, utworzenie relacji jeden-wiele między ogłoszeniami i zdjęciami oraz przygotowanie modelu Photo do pracy z pluginem attachment_fu (wykorzystanie relacji wiele-wiele pozostawiam jako ćwiczenie). W tym celu edytujemy pliki modeli jak poniżej

# app/models/advertisement.rb
class Advertisement < ActiveRecord::Base
  has_many :photos
end
# app/models/photo.rb
class Photo < ActiveRecord::Base
  belongs_to :advertisement
  has_attachment :content_type => :image,   # zalacznikiem jest obrazek
                 :storage => :file_system,  # trzymamy go w systemie plikow
                 :max_size => 2.megabytes,  # ograniczamy rozmiar do 2 MB
                 :resize_to => '1200x800>', # zmniejszamy plik wejsciowy
                 :thumbnails => {:thumb => '200x200>'} # tworzymy jedna miniature
  validates_as_attachment # walidujemy plik po zaladowaniu na serwer, przed zapisem do bazy danych
  # z powyzsza walidacja mialem problemy pod Windowsem, posiadaczom tego systemu radze poki co zakomentowac
  # powyzsza linijke
end

W tej chwili mamy już skonfigurowaną relację oraz przygotowany model pod umieszczanie na serwerze plików. Pora na następną część

Konfiguracja kontrolerów

Do prawidłowej pracy będziemy potrzebować dwa kontrolery. Jeden z nich, nazwijmy go advertisements będzie odpowiedzialny za zarządzanie ogłoszeniami, drugi — photos — będzie zajmował się zdjęciami. Wykorzystamy mechanizm REST. W tym celu tworzymy kontrolery z linii poleceń:

$ script/generate controller Advertisements index show new edit create update destroy
$ script/generate controller Photos index new edit create destroy

Aby skorzystać z REST musimy jeszcze dodać odpowiednie wpisy w pliku config/routes.rb:

ActionController::Routing::Routes.draw do |map|
  map.resources :advertisements
  map.resources :photos
  
  # ponizsze wpisy pozostawiamy
  map.connect ':controller/:action/:id'
  map.connect ':controller/:action/:id.:format'
end

Nadeszła pora na zaimplementowanie akcji potrzebnych do zarządzania naszymi zasobami. Są to standardowe akcje, pokażę zatem jedynie zawartość kontrolerów:

# app/controllers/advertisements_controller.rb
class AdvertisementsController < ApplicationController

  def index
    @advertisements = Advertisement.find(:all)
    respond_to do |format|
      format.html {}
      format.xml  { render :xml => @advertisements.to_xml }
    end
  end

  def show
    @advertisement = Advertisement.find(params[:id])
    respond_to do |format|
      format.html {}
      format.xml  { render :xml => @advertisement.to_xml }
    end
  end

  def new
    @advertisement = Advertisement.new
  end

  def edit
    @advertisement = Advertisement.find(params[:id])
  end

  def create
    @advertisement = Advertisement.new(params[:advertisement])
    respond_to do |format|
      if @advertisement.save
        format.html { redirect_to advertisement_url(@advertisement) }
        format.xml  { render :xml => @advertisement.to_xml }
      else
        format.html { render :action => 'new'}
        format.xml  { render :xml => @advertisement.errors.to_xml }
      end
    end
  end

  def update
    @advertisement = Advertisement.find(params[:id])
    respond_to do |format|
      if @advertisement.update_attributes(params[:advertisement])
        format.html { redirect_to advertisement_url(@advertisement) }
        format.xml  { render :xml => @advertisement.to_xml }
      else
        format.html { redirect_to edit_advertisement_url(@advertisement) }
        format.xml  { render :xml => @advertisement.errors.to_xml }
      end
    end
  end

  def destroy
    @advertisement = Advertisement.find(params[:id])
    respond_to do |format|
      if @advertisement.destroy
        format.html { redirect_to advertisements_url }
        format.xml  { head :ok }
      else
        format.html { redirect_to advertisement_url(@advertisement) }
        format.xml  { redirect_to @advertisement.errors.to_xml }
      end
    end
  end
end
# app/controllers/photos_controller.rb
class PhotosController < ApplicationController

  def index
    @photos = Photo.find(:all, :conditions => 'parent_id IS NULL') # wybieramy tylko zdjecia w oryginalnych rozmiarach
    respond_to do |format|
      format.html {}
      format.xml  { render :xml => @photos.to_xml }
    end
  end

  def new
    @photo = Photo.new
    @advertisements = Advertisement.find(:all)
  end

  def create
    @photo = Photo.new(params[:photo])
    respond_to do |format|
      if @photo.save
        format.html { redirect_to photos_url }
        format.xml  { render :xml => @photo.to_xml }
      else
        format.html { render :action => 'new' }
        format.xml  { render :xml => @photo.errors.to_xml }
      end
    end
  end

  def destroy
    @photo = Photo.find(params[:id])
    respond_to do |format|
      if @photo.destroy
        flash[:notice] = 'Zdjęcie zostało usunięte'
        format.html { redirect_to photos_url }
        format.xml  { head :ok }
      else
        flash[:error] = 'Wystąpił błąd podczas usuwania zdjęcia'
        format.html { redirect_to photos_url }
        format.xml  { render :xml => @photo.errors.to_xml }
      end
    end
  end
end

Warstwa modeli i kontrolerów jest już prawie gotowa. Pozostało dodanie widoków, za pomocą których będziemy mogli odczuć, czy to, co stworzyliśmy w ogóle działa.

Pora na widoki

W warstwie widoków ograniczę się do najprostrzego wyświetlania elementów, bez żadnych upiększaczy (przynajmniej na razie). Poniżej wklejam najprostsze możliwe widoki, które nie oferują nic poza podstawowymi opcjami. Widoki dla kontrolera advertisements_controller:

# app/views/advertisements/index.html.erb
<h1>Ogłoszenia</h1>
<ul>
<% for advertisement in @advertisements %>
  <li>
    <p><%= link_to h(advertisement.name), advertisement_url(advertisement) %></p>
    <p><%= truncate(h(advertisement.description), 20) %></p>
    <p><%= link_to 'Edytuj', edit_advertisement_url(advertisement) %></p>
    <p><%= link_to 'Usuń', advertisement_url(advertisement), :method => :delete, :confirm => 'Czy jesteś pewien?' %></p>
  </li>
<% end %>
</ul>
<p><%= link_to 'Dodaj nowe', new_advertisement_url %></p>

# app/views/advertisements/new.html.erb
<h1>Nowe ogłoszenie</h1>
<%= error_messages_for :advertisement %>

<% form_for @advertisement do |f| %>
  <p><label for="advertisement_name">Nazwa:</label></p>
  <p><%= f.text_field :name %></p>
  <p><label for="advertisement_description">Opis:</label></p>
  <p><%= f.text_area :description, :cols => 60, :rows => 20 %></p>
  <p><%= submit_tag 'Zapisz', :disable_with => "Proszę czekać..." %></p>
<% end %>
<p><%= link_to 'Powrót', advertisements_url %></p>

# app/views/advertisements/show.html.erb
<h1><%=h @advertisement.name %></h1>
<p><%=h @advertisement.description %></p>
<p><%= link_to 'Edytuj', edit_advertisement_url(@advertisement) %></p>
<p><%= link_to 'Usuń', advertisement_url(@advertisement), :method => :delete, :confirm => 'Czy jesteś pewien?' %></p>
<p><%= link_to 'Powrót', advertisements_url %></p>
<hr/>
<% unless @advertisement.photos.empty? %>
<p>Zdjęcia:</p>
<ul>
<% for photo in @advertisement.photos %>
  <li><%= link_to image_tag(photo.public_filename(:thumb)), photo.public_filename %></li>
<% end %>
</ul>
<% end %>

# app/views/advertisements/edit.html.erb
<h1>Edycja ogłoszenia</h1>
<%= error_messages_for :advertisement %>

<% form_for @advertisement do |f| %>
  <p><label for="advertisement_name">Nazwa:</label></p>
  <p><%= f.text_field :name %></p>
  <p><label for="advertisement_description">Opis:</label></p>
  <p><%= f.text_area :description, :cols => 60, :rows => 20 %></p>
  <p><%= submit_tag 'Zapisz', :disable_with => "Proszę czekać..." %></p>
<% end %>
<p><%= link_to 'Powrót', advertisements_url %></p>

Widoki dla kontrolera photos_controller:

# app/views/photos/index.html.erb
<h1>Zdjęcia</h1>
<ul>
<% for photo in @photos %>
  <li>
    <p><%= link_to image_tag(photo.public_filename(:thumb)), photo.public_filename %></p>
    <p><%= link_to 'Usuń', photo_url(photo), :method => :delete, :confirm => 'Czy jesteś pewien?' %></p>
  </li>
<% end %>
</ul>
<p><%= link_to 'Dodaj nowe', new_photo_url %></p>

# app/views/photos/new.html.erb
<h1>Nowe zdjęcie</h1>
<%= error_messages_for :photo %>

<% form_for @photo, :html => {:multipart => true} do |f| %>
  <p><label for="photo_uploaded_data">Plik ze zdjęciem:</label></p>
  <p><%= f.file_field :uploaded_data %></p>
  <p><label for="photo_advertisement_id">Ogłoszenie:</label></p>
  <p><%= f.collection_select :advertisement_id, @advertisements, :id, :name, {:prompt => true} %></p>
  <p><%= f.submit "Załaduj" %></p>
<% end %>
<p><%= link_to 'Powrót', photos_url %></p>

W tej chwili możemy już zobaczyć, jak to wszystko działa. Wystarczy odpalić serwer:

$ script/server

Po odpaleniu serwera wpisując w adresie przeglądarki adres http://localhost:3000/advertisements możemy administrować ogłoszeniami, natomiast pod adresem http://localhost:3000/photos możemy zarządzać zdjęciami.

No dobrze, ale na początku pisałem o dynamicznym ładowaniu zdjęć podczas pisania ogłoszenia oraz o możliwości wybierania zdjęć. Na pierwszy rzut pójdzie możliwość wyboru zdjęć spośród załadowanych i nie dołączonych do innych ogłoszeń

Wybór zdjęć przy dodawaniu ogłoszenia

Aby umożliwić dołączanie zdjęć podczas dodawania czy edycji ogłoszenia, skorzystamy z możliwości, jakie dostarcza metoda has_many w modelu ogłoszeń, a dokładnie z settera photo_ids. W tym celu należy dodać kilka rzeczy do kontrolera ogłoszeń i zmodyfikować odpowiednio widoki.

W metodach new oraz edit dodajemy następującą linijkę (oczywiście przeczymy tutaj zasadzie DRY, jednak chodzi tutaj o jak najprostsze pokazanie, o co chodzi):

@photos = Photo.find(:all, :conditions => 'parent_id IS NULL')

W tej chwili możemy już pokazać w formularzu dodawania/edycji ogłoszeń dostępne zdjęcia wraz z check boxami do ich zaznaczania. W tym celu dodajemy do formularza w plikach app/views/advertisements/{new,edit} gdzieś w bloku form_for, najlepiej po text area:

<div id="photos">
<% for photo in @photos %>
  <p><%= image_tag photo.public_filename(:thumb) %></p>
  <p><%= check_box_tag 'advertisement[photo_ids][]', photo.id, @advertisement.photos.include?(photo) %>
<% end %>
</div>

Od tej pory będziemy mogli zaznaczać zdjęcia, które będziemy chcieli dodać do ogłoszenia (pamiętajmy o tym, że mamy relację jeden-wiele, więc jeżeli tworząc lub edytując jakieś ogłoszenie zaznaczymy zdjęcie wcześniej zajęte, zostanie ono przypisane tylko do nowo tworzonego/edytowanego ogłoszenia — aby to zmienić należy zamienić relację z jeden-wiele na wiele-wiele). Zostaje tylko jeden problem. Kiedy będziemy chcieli usunąć z jakiegoś ogłoszenia wszystkie zdjęcia i odznaczymy wszystkie dostępne w formularzu, po kliknięciu 'Dodaj' lub 'Edytuj' nic się nie stanie. Dzieje się tak dlatego, że w tym przypadku Railsy nie przekażą do kontrolera zawartości parametru advertisement[photo_ids][]. Jednak jest bardzo prosty sposób, by to zmienić. Do kontrolera advertisements_controller w akcji update dodajemy na początku następującą linijkę:

params[:advertisement][:photo_ids] ||= []

Spowoduje ona, że w przypadku, gdy odznaczymy wszystkie zdjęcia podczas edycji ogłoszenia, zostaną rzeczywiście usunięte z relacji.

Dynamiczne dodawanie plików + a pinch of AJAX

Do zrobienia została nam jeszcze jedna część — dynamiczne ładowanie zdjęcia podczas dodawania lub edycji ogłoszenia i uaktualnianie formularza nowym zdjęciem. Nie da się tego zrobić przy pomocy samego AJAX-a, ponieważ ze względów bezpieczeństwa AJAX nie pozwala na przesyłanie lokalnych plików. Istnieje jednak prosty sposób, aby tę niedogodność ominąć. W tym celu utworzymy na stronie niewodoczny iframe i ustawimy na niego atrybut target formularza ładującego zdjęcie. Następnie, wykorzystując plugin responds_to_parent, uaktualnimy listę zdjęć w formularzu dotyczącym ogłoszenia.

Na początku zdefiniujmy sobie prosty layout oraz dołączmy potrzebne skrypty JavaScript. W tym celu tworzymy plik app/views/layouts/application.erb o nastepującej zawartości:

<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
    <title>Demonstracja dynamicznego ładowania plików, &copy; 2008 GhandaL</title>
    <%= javascript_include_tag :defaults %>
  </head>
  <body>
    <% if flash[:notice] %>
      <div style="border: 1px green"><%= flash[:notice] %></div>
    <% end %>
    <% if flash[:error] %>
      <div style="border: 1px red"><%= flash[:error] %></div>
    <% end %>
    <%= yield :layout %>
  </body>
</html>

W celu ładowania pliku posłużymy się fragmentami kodu widoków, tzw. partials. Zanim jednak dopracujemy widoki, zmodyfikujemy odpowiednio kontroler photos_controller, aby uaktualniał stronę, która wywołała akcję create. W tym celu modyfikujemy akcję create w pliku app/controllers/photos_controller.rb tak, by wyglądała jak poniżej:

  def create
    @photo = Photo.new(params[:photo])
    respond_to do |format|
      if @photo.save
        format.html { redirect_to photos_url }
        format.xml  { render :xml => @photo.to_xml }
        format.js do
          responds_to_parent do
            render :update do |page|
              page.insert_html :bottom, "photos", :partial => 'photo', :object => @photo
            end
          end
        end
      else
        format.html { render :action => 'new' }
        format.xml  { render :xml => @photo.errors.to_xml }
        format.js do
          responds_to_parent do
            render :update do |page|
              # jakis kod uaktualniajacy, np. bledem, strone
            end
          end
        end
      end
    end
  end

Teraz możemy się zająć stworzeniem odpowiednich partials. Po pierwsze stworzymy kod, który wyświetli nowo załadowane zdjęcie. Tworzymy plik app/views/photos/_photo.erb o zawartości:

<p><%= image_tag photo.public_filename(:thumb) %></p>
<p><%= check_box_tag 'advertisement[photo_ids][]', photo.id, true %></p>

Do zrobienia została nam jeszcze jedynie modyfikacja widoków akcji new i edit kontrolera advertisements_controller. Gdzieś na końcu plików app/views/advertisements/{new,edit} dopisujemy następujący kod:

<iframe name="dummy" id="dummy" style="width:1px;height:1px;border:0"></iframe>
<% form_for :photo, :url => {:controller => :photos, :action => :create, :format => 'js'}, :html => {:target => 'dummy', :multipart => true} do |f| %>
  <p><label for="photo_uploaded_data">Plik ze zdjęciem:</label></p>
  <p><%= f.file_field :uploaded_data %></p>
  <p><%= f.submit 'Dodaj' %></p>
<% end %>

I to już wszystko. Od tej pory dodając i edytując ogłoszenia można już ładować zdjęcia bez potrzeby przeładowywania strony.

Konkluzja

Oczywiście powyższe rozwiązanie ma pewne wady. Przede wszystkim relacja jeden-wiele jest bardzo prymitywna w tym przypadku i zabrania dołączania jednego zdjęcia do kilku ogłoszeń. Jednak modyfikacja tego nie jest trudna, nie o tym jest również ta notka. Kod demonstracyjnej aplikacji możesz ściągnąć mojej strony. Mam nadzieję, że powyższy opis komuś się przyda. Jeśli zauważyłeś jakiś błąd lub chciałbyś wyrazić opinię o moim rozwiązaniu, zapraszam do komentowania.

Tagi:

Komentarze do notki “Dynamiczne ładowanie plików z formularza w Ruby on Rails”:

  1. Seban

    Według mnie dobry artykuł! Nie znałem wcześniej pluginu responds_to_parent. Osobiście też wolę używać w kontrolerach, metod wznoszących wyjątek w przypadku błędu (save!, create! itp.), ale to kwestia gustu jest tylko. Naturalnym rozwinięciem chyba byłaby możliwość przeglądania galerii zdjęć z ogłoszenia advertisment/#{:id}/photos. Ja bym tak to właśnie robił.
    Warto też chba używać dynamicznie generowanych helperów by zastąpić:
    > :url => {:controller => :photos, :action => :create, ...
    :url => photos_path

  2. GhandaL

    Dzięki za uwagi, rzeczywiście można było skorzystać z photos_path, w tym przypadku z parametrem ‘js’. Moje przeoczenie ;-) A co do galerii zdjęć z ogłoszenia to przecież wystarczy jedno nested resources, a to żadna trudność.

  3. zielony chlopaczyna

    Witam,
    napotkalem na twojego bloga, zainspirowales mnie jak ladnie i szybko Ci to wyszlo:) Ogolnie z tej paczuszki co sciagnalem (demo) to mi dziala:)
    Jednak chcialem zrobic to co masz tutaj wykorzystujac plugin paperclip do uploadu zdjec zamiast attachemnt-fu i mi nie wychodzi. Ogolnie mam tak ze w ogloszeniu jak dodaje zdjecie to nie mam mozliwosci zaznaczenia checkboxa (zdjecie sie nie pokazuje) Ps.mam zainstalowany response to patern.

    Moglbys zerknac fachowym okiem na moje kody [link] http://rapidshare.com/files/211776788/pomocy.rar [/link] i powiedziec co jest tam nie tak ?
    Ogolnie mysle ze gdzies w moim kontrolerze pictures tam co sa te |js| ale nie wiem.

    Proszę bardzo o pomoc.
    A tu moj e-mail lovelas6667@wp.pl

Zostaw komentarz (Textile włączony):