Upload Multiple File Image on Rails

Giới thiệu vấn đề ?

Trong quá trình tạo một trang web với rails chắc hẳn chúng ta đều phải tạo chức năng insert và update các file, đặc biệt là các file ảnh để hiển thị. Một cách thiết đặt dababase phổ biến cho việc upload ảnh của một đối tượng nào đó là tạo quan hệ has many như sau:

  class Room < ApplicationRecord
    has_many :images, dependent: :destroy
  end  
  class Image < ApplicationRecord
    belongs_to :room
    mount_uploader :image_link, ImageUploader
  end

Khi tạo một room gồm một nhiều ảnh nên ta cần upload nhiều ảnh cùng 1 lúc. Vì vậy, bài viết này mình xin hướng dẫn các bạn một số cách upload nhiều file ảnh cùng lúc khi tạo room. Trong ví dụ này, mình dùng gem CarrierWave để lưu trữ ảnh.

Một số cách giải quyết ?

Để xử lý yêu cầu này, mình xin giới thiệu 3 cách sau:

  • Cách 1. Sử dụng transaction
  • Cách 2. Áp dụng kĩ thuật nested attributes
  • Cách 3. Sử dụng nested attributes kết hợp với gem cocoon

Sử dụng Transaction ?

Trong view tạo room ta cài đặt như sau:

  <%= f.fields_for :images do |p| %>
    <div class="field">
      <%= p.label :images %><br>
      <%= p.file_field :image_link, multiple: true, name: "images[image_link][]" %>
    </div>
  <% end %>

Phần xử lý trong controller:

  def new
    @room = Room.new
    @image = @room.images.build
  end

  def create
    @room = Room.new room_params
    insert_data
    redirect_to admin_rooms_path

  def insert_data
    ActiveRecord::Base.transaction do
      @room.save
      params[:images]["image_link"].each do |image|
        @image = Image.create room_id: @room.id, image_link: image
        @image.save
      end
    end
  end

Sử dụng kĩ thuật Nested Attributes ?

Nested attribues là gì ?

Nested Attributes là một tính năng cho phép chúng ta lưu bản ghi này thông qua bản ghi khác (associated records)

Cách cài đặt

Mặc định trong rails thì nested atrributes updating được disable và bạn có thể kích hoạt nó bằng cách sử dụng phương thức accepts_nested_attributes_for trong model tương ứng. app/models/room.rb

  class Room < ActiveRecord::Base
    has_many :images, dependent: :destroy
    accepts_nested_attributes_for :images
  end

Ví dụ khi bạn sử dụng accepts_nested_attributes_for :images trong model Room thì khi create hoặc update cho đối tượng room bạn có thể create/update luôn cho images bằng cách truyền thuộc tính của images vào room_params.

  def room_params
    params.require(:room).permit :category_id, :label, :floor, :status,
      images_attributes: [:id, :room_id, :image_link]
  end

Vài lưu ý khi sử dụng nó

:allow_destroy Theo mặc định, bạn sẽ chỉ có thể thiết lập và cập nhật các thuộc tính trên mô hình liên quan. Nếu bạn muốn hủy mô hình liên kết thông qua các thuộc tính băm, bạn phải kích hoạt nó trước bằng cách sử dụng tùy chọn: allow_destroy. Cập nhật bản ghi bằng các thuộc tính hoặc đánh dấu nó để hủy nếu allow_destroy là true và has_destroy_flag? trả về true.

:reject_if Bạn cũng có thể thiết lập một: reject_if proc để âm thầm bỏ qua bất kỳ bản ghi mới nếu nó không vượt qua được tiêu chí của bạn.

:limit Cho phép bạn chỉ định số lượng tối đa của các record liên quan có thể được xử lý với các thuộc tính lồng nhau. :limit option chỉ áp dụng cho quan hệ one-to-many.

:update_only Cho phép bạn chỉ định một record chỉ có thể được update thôi. Một record mới chỉ được tạo ra khi không có record nào hiện có. :update_only chỉ hoạt động cho quan hệ one-to-one.

Cách 1. Sử dụng multiple: true trong tag file_field

Trong view tạo room

  <%= f.fields_for :images do |p| %>
    <div class="field">
      <%= p.file_field :image_link, multiple: true, name: "images[image_link][]" %>
    </div>
  <% end %>

Trong rooms_controller

  def new
    @room = Room.new
    @image = @room.images.build
  end

  def create
    @room = Room.new room_params
    if @room.save
      params[:images]['image_link'].each do |a|
        @image = @room.images.create!(:image_link => a)
      end    
    redirect_to admin_rooms_path
  end 

  def update
    if @room.update_attributes room_params
      flash[:success] = t ".success"
      redirect_to admin_rooms_path
    else
      render :edit
    end
  end    

  def room_params
    params.require(:room).permit :category_id, :label, :floor, :status,
      images_attributes: [:id, :room_id, :image_link]
  end

Cách 2. Sử dụng for hoặc each

Đơn giản là bạn sử dụng vòng lặp, bạn muốn thêm bao nhiêu đối tượng images phía trên thì sẽ lặp bấy nhiêu lần, cách này có một nhược điểm là bạn phải gán cố định số vòng lặp tất nhiên nó sẽ làm cho tính tùy biến trong ứng dụng của chúng ta giảm xuống.
Trong view tạo room

  <%= f.fields_for :images do |p| %>
    <div class="field">
      <%= p.file_field :image_link %>
    </div>
  <% end %>

Trong rooms_controller

  def new
    @room = Room.new
    4.times do
      @image = @room.images.build
    end
  end

  def create
    @room = Room.new room_params
    if @room.save
      flash[:success] = t ".create_room_successful"
      redirect_to admin_rooms_path
    else
      flash.now[:warning] = t ".create_room_fail"
      render :new
    end
  end

  def update
    if @room.update_attributes room_params
      flash[:success] = t ".success"
      redirect_to admin_rooms_path
    else
      render :edit
    end
  end

  def room_params
    params.require(:room).permit :category_id, :label, :floor, :status,
      images_attributes: [:id, :room_id, :image_link]
  end

Cách 3. Sử dụng link_to_add_fields

Cách này sẽ tạo ra một button mà khi click sẽ cho phép bạn sinh ra một đối tượng mới, mà cụ thể ở đây là đối tượng images. Trong code js bạn thêm function add_fields. Hàm này sẽ lặp lại một đối tượng mà bạn truyền vào với một id mới.

function add_fields(link, association, content) {  
    var new_id = new Date().getTime();  
    var regexp = new RegExp("new_" + association, "g");  
    $(link).parent().before(content.replace(regexp, new_id));  
}

Trong form của bạn.

<%= link_to_add_fields "Add a Room", f, :images %>

Trong application_helper

def link_to_add_fields(name, f, association, cssClass, title)  
  new_object = f.object.class.reflect_on_association(association).klass.new  
  fields = f.fields_for(association, new_object, :child_index => "new_#{association}") do |builder|  
    render(association.to_s.singularize + "_fields", :f => builder)  
  end  
  link_to name, "#", :onclick => h("add_fields(this, \"#{association}\", \"#{escape_javascript(fields)}\")"), :class => cssClass, :title => title 
end  

Sử dụng nested attributes kết hợp với gem cocoon

Ích lợi của way này:
Upload nhiều file cùng lúc
Thêm hoặc remove input fields một cách thoải mái
Xem các ảnh trước khi được upload
Khi xảy ra lỗi có thể lưu lại cái field đã nhập hay cái ảnh đã chọn.
Gemfile.rb

gem "cocoon" // hỗ trợ nested form

app/assets/js/application.js

//= require cocoon

Tiếp theo ở view ta tạo 1 view là new.htmt.erb:

    <%= form_for @room do |f| %>
    <div class="form-group">
      <%= f.label :images, class: "col-md-4 control-label"%>
      <div class="col-md-4">
        <table class="user-photo-form">
          <%= f.fields_for :images do |image| %>
            <%= render "image_fields", f: image %>
          <% end %>
          <%= link_to_add_association (t "add_a_photo"), f, :images, class: "btn btn-default" %>
        </table>
      </div>
    </div>
    <% f.submit "Save "%>

Sau đó ta tạo 1 Partial _image_link_fields.html.erb (image_link là 1 trường của bảng 1 images)

<tr class="nested-fields"> // class này dùng để gem cocoon nhận biết đây là phần để nó append vào mỗi khi ấn nút
  <td>
    <%= f.file_field :image %>
    <%= f.hidden_field :image_cache, value: f.object.image_cache %>
  </td>
  <td class="thumb"> // để js nhận biết append vào các preview image
    <% if f.object.image.url.present? %>
      <%= image_tag f.object.image.url %>
    <% end %>
  </td>
  <td>
    <%= link_to_remove_association (t "remove"), f %>
  </td>
</tr>

Cuối cùng, chúng ta chỉ cần tạo 1 file room.js.coffee như sau:

$ ->
  onAddFile = (event) ->
    file = event.target.files[0]
    url = URL.createObjectURL(file)
    thumbContainer = $(this).parent().siblings('td.thumb')
    if thumbContainer.find('img').length == 0
      thumbContainer.append '<img src="' + url + '" />'
    else
      thumbContainer.find('img').attr 'src', url

  $('input[type=file]').each ->
    $(this).change onAddFile
  $('body').on 'cocoon:after-insert', (e, addedPartial) ->
    $('input[type=file]', addedPartial).change onAddFile
  $('a.add_fields').data 'association-insertion-method', 'append'
  $('a.add_fields').data 'association-insertion-node', 'table.user-photo-form tbody'

Tổng kết

Trên đây là một số cách để chúng ta có thể thêm nhiều ảnh khi tạo một đối tượng has_many images. Có cách chưa được tối ưu, bạn nào có cách làm hay và hiệu quả hơn thì chia sẻ cho mọi người dưới comment nhé.
Cảm ơn các bạn đã đọc bài viết. Chúc mọi người có 1 ngày làm việc hiệu quả.

Tham khảo

Bài viết được tham khảo từ các bài về nested attributes trên Viblo. https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html http://www.liooo.engineer/blog/2014/11/22/building-multiple-file-upload-form-in-rails-way/ https://stackoverflow.com/search?q=Nested+Attribute+Image++Rails+


All Rights Reserved