12/08/2018, 13:31

Build form object với gem reform trong Rails

Xin chào các bạn, hôm nay mình sẽ giới thiếu tới mọi người một cách để refactor code tránh bị "fat models" đó là sử dụng Form object . Tuy nhiên chúng ta sẽ sử dụng một gem để hỗ trợ cho việc xây dựng lên form object là Reform 1 Chuẩn bị project Đầu tiên chúng ta chuẩn bị 1 project không có ...

Xin chào các bạn, hôm nay mình sẽ giới thiếu tới mọi người một cách để refactor code tránh bị "fat models" đó là sử dụng Form object. Tuy nhiên chúng ta sẽ sử dụng một gem để hỗ trợ cho việc xây dựng lên form object là Reform

1 Chuẩn bị project

Đầu tiên chúng ta chuẩn bị 1 project không có test

$ rails reform_demo -T

Khởi tạo model Course

$ rails generate model Course name:string description:text

Khởi tạo model Reference

$ rails generate model Reference title:string course:references

Khai báo relation giữa model CourseReference

# app/models/course.rb

class Course < ActiveRecord::Base
  has_many :references, dependent: :destroy
end

# app/models/reference.rb

class Reference < ActiveRecord::Base
  belongs_to :course
end

Khai báo gem Reform

# Gemfile
[...]
gem "reform"
[...]

Sau đó các bạn nhớ chạy lệnh bundle install. Như vậy là việc cài đặt đã xong, tiếp theo chúng ta sẽ sang phần sử dụng gem và xậy dựng form object.

2 Sử dụng gem

Tạo courses_controlelr.rb 3 hàm create, new và show:

# app/controllers/courses_controller.rb

class CoursesController < ApplicationController
  def new

  end

  def create

  end

  def show

  end
end

Định nghĩa trong file routes:

# config/routes

Rails.application.routes.draw do
  resources :courses
end

Khởi tạo course form trong folder forms:

# app/forms/course_form.rb

class CourseForm < Reform::Form
  property :name
  property :description
  validates :name, presence: true

  # nesting
  collection :references do
    property :name
    validates :title, presence: true
  end
end

Các thuộc tính của model sẽ được khai báo bằng các property. Thuộc tính collection để khai báo rằng có nhiều references trong 1 course. Chúng ta sẽ khai báo các validate trong form thay vì phải khai báo trong model.

Tiếp theo chúng ta sẽ khởi tạo 1 instance trong controller course để sử dụng như sau:

# app/controllers/courses_controller.rb
[...]
def new
  course = Course.new # khởi tạo 1 course
  course.references.build # khởi tạo 1 references của course
  @course_form = CourseForm.new course
end
[...]

Khi render ra view:

# app/views/courses/new.html.erb

<div class="row">
  <%= form_for @course_form do |f| %>
    <%= render "shared/errors_messages", object: @course_form %>

    <div class="col-md-8">
      <div class="box box-info box-solid">
        <div class="box-header with-border">
          <h3 class="box-title">
            <strong>Course information</strong>
          </h3>
        </div>

        <div class="box-body">
          <div class="form-group">
            <%= f.label :name, class: "control-label" %>
            <%= f.text_field :name, class: "form-control" %>
          </div>

          <div class="form-group">
            <%= f.label :description, class: "control-label" %>
            <%= f.text_area :description, class: "form-control" %>
          </div>
        </div>
      </div>
    </div>

    <div class="col-md-4">
      <div class="box box-success box-solid">
        <div class="box-header with-border">
          <h3 class="box-title">
            <strong>References</strong>
          </h3>
        </div>

        <div class="box-body">
          <%= f.fields_for :references do |reference| %>
            <%= render "reference_fields", f: reference %>
          <% end %>
        </div>
      </div>
    </div>

    <div class="col-xs-12">
      <div class="form-group">
        <%= f.submit "Submit", class: "btn btn-info" %>
      </div>
    </div>
  <% end %>
</div>

# app/views/courses/_reference_fields.html.erb

<div class="row">
  <div class="col-xs-12">
    <div class="col-xs-11">
      <div class="form-group">
        <%= f.text_field :name, class: "form-control" %>
      </div>
    </div>
  </div>
</div>

Và đây là những gì chúng ta thu được example.png

view.png

Chúng ta có thể thấy rằng form được tạo có các thẻ tương đối giống với việc sử dụng nested attributes. Tuy nhiên có 1 khác biệt duy nhất là tên của thẻ input là "references_attributes" thay cho "reference_attributes".

Việc tạo form đã xong tiếp theo chúng ta sẽ submit form để lưu vào trong cơ sở dữ liệu. Trong hàm create sẽ xử lí như sau:

# app/controllers/courses_controller.rb
[...]
  def create
    @course = Course.new
    params[:course][:references_attributes].each do |_, value|
      @course.references.build
    end

    @course_form = CourseForm.new @course
    if @course_form.validate params[:course].permit! # accept strong params
      @course_form.save # method save được hỗ trợ bởi gem.
      flash[:notice] = "Course was created successfully!"
      redirect_to @course
    else
      flash[:alert] = "Course could not be created!"
      render :new
    end
  end
[...]

Như vậy là chúng ta đã hoàn thành việc tạo course và tạo references của course đó. Tương tự cho việc update 1 course chúng ta sẽ thực hiện như sau:

# app/controllers/courses_controller.rb

[...]
  def edit
    course = Course.find params[:id]
    @course_form = CourseForm.new course
  end

  def update
    course = Course.find params[:id]
    @course_form = CourseForm.new @course

    if @course_form.validate params[:course].permit!
      @course_form.save
      flash[:notice] = "Course was updated successfully!"
      redirect_to [:admin, @course]
    else
      flash[:alert] = "Course could not be updated!"
      render :edit
    end
  end
[...]

# app/views/courses/edit.html.erb
<div class="row">
  <%= form_for @course_form do |f| %>
    <%= render "shared/errors_messages", object: @course_form %>

    <div class="col-md-8">
      <div class="box box-info box-solid">
        <div class="box-header with-border">
          <h3 class="box-title">
            <strong>Course information</strong>
          </h3>
        </div>

        <div class="box-body">
          <div class="form-group">
            <%= f.label :name, class: "control-label" %>
            <%= f.text_field :name, class: "form-control" %>
          </div>

          <div class="form-group">
            <%= f.label :description, class: "control-label" %>
            <%= f.text_area :description, class: "form-control" %>
          </div>
        </div>
      </div>
    </div>

    <div class="col-md-4">
      <div class="box box-success box-solid">
        <div class="box-header with-border">
          <h3 class="box-title">
            <strong>References</strong>
          </h3>
        </div>

        <div class="box-body">
          <%= f.fields_for :references do |reference| %>
            <%= render "reference_fields", f: reference %>
          <% end %>
        </div>
      </div>
    </div>

    <div class="col-xs-12">
      <div class="form-group">
        <%= f.submit "Submit", class: "btn btn-info" %>
      </div>
    </div>
  <% end %>
</div>

3 Nâng cao

Tương tự như việc sử dụng nested attributes, ở đây chúng ta thêm và xóa references của course một cách động như sau.

# app/helpers/application_helper

module ApplicationHelper
  def link_to_add_fields name, f, association
    new_object = f.object.model.send(association).klass.new
    id = new_object.object_id

    fields = f.fields_for(association, new_object, child_index: id) do |builder|
      render association.to_s.singularize + "_fields", f: builder
    end

    link_to name, "#", class: "add_fields", data: {id: id, fields: fields.gsub("
", "")}
  end
end

# app/assets/javascripts/application.js
[...]
  $("form").on("click", ".remove_fields", function(event) {
    $(this).prev("input[type=hidden").val("1");
    $(this).closest(".row").hide();
    event.preventDefault();
  });

  $("form").on("click", ".add_fields", function(event) {
    time = new Date().getTime();
    regexp = new RegExp($(this).data("id"), "g");
    $(this).before($(this).data("fields").replace(regexp, time));
    event.preventDefault();
  });
[...]

File "_reference_fields.html.erb" sẽ được sửa lại như sau:

# app/views/courses/_reference_fields.html.erb

<div class="row">
  <div class="col-xs-12">
    <div class="col-xs-11">
      <div class="form-group">
        <%= f.text_field :name, class: "form-control" %>
      </div>
    </div>

    <div class="col-xs-1">
      <%= f.hidden_field :_destroy %>
      <%= link_to "#", class: "remove_fields" do %>
        <i class="fa fa-times" aria-hidden="true"></i>
      <% end %>
    </div>
  </div>
</div>

Trong file "courses/new.html.erb" và "courses/edit.html.erb" chúng ta sẽ bổ sung thêm như sau:

[...]
<%= f.fields_for :references do |reference| %>
  <%= render "reference_fields", f: reference %>
<% end %>
<%= link_to_add_fields "Add more", f, :references %>
[...]

Đến đây công việc của chúng ta cơ bản là đã hoàn thành, tuy nhiên khi cập nhật course sẽ xảy ra hiện tượng duplicate references và khi muốn xóa đi references trong khi chúng ta edit cũng sẽ không thành công. Điều này là do chưa có khai báo allow_destroy, nhưng trong gem reform thì chúng ta không thể nào khai báo allow_destroy: true giống như khai báo trong nested form được. Ở đây chúng ta sẽ cần phải custom lại method save và chỉnh sửa một chút course form như sau:

# app/forms/course_form.rb
class CourseForm < Reform::Form
  property :name
  property :description

  collection :references, populate_if_empty: Reference do
    property :id, writeable: false
    property :name
    property :_destroy, writeable: false
  end

  def save
    super do |attrs|
      if model.persisted? # trong trường hợp update
        # remove nếu như bị xóa trên view
        to_be_removed = ->(i) {i[:_destroy] == "1"}
        reference_ids_to_rm = attrs[:references].select(&to_be_removed).map {|i| i[:id]}
        Reference.destroy reference_ids_to_rm
        references.reject! {|i| reference_ids_to_rm.include? i.id}

        # reject nếu như references title rỗng
        references.reject! {|i| i.title.blank?}
      else # trong trường hợp create
        references.reject! {|i| i._destroy == "1"}
        references.reject! {|i| i.title.blank?}
      end
    end

    super # tiếp tục thực hiện như cũ
  end
end

Ngoài ra chúng ta cũng có thể bổ sung thêm các validate cho form như sau:

# app/forms/course_forms.rb
[...]
  validate :validate_reference_title
[...]
[...]
  def validate_reference_title
    # ở đây chúng ta sẽ kiếm tra title của reference đã tồn tại không được phép rỗng
    references.each do |reference|
      if Reference.find_by_id(reference.id).present? && reference.name.blank?
        errors.add :reference, "Title can not blank"
      end
    end
  end
[...]

Trên đây chúng ta đã hoàn thành việc tạo form object bằng gem reform. Trong bài viết tôi có sử dụng thêm giao diện của adminLte để hỗ trợ việc hiển thị. Cảm ơn các bạn đã theo dõi

0