74
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Ruby on RailsAdvent Calendar 2016

Day 3

Railsで動的フォーム入力画面を作る

Last updated at Posted at 2016-12-03

2016 Railsアドベントカレンダータイトル 「え? ActiveModelのバリデーション使いつつ、動的に入力フォーム定義する? できらぁ!」

背景

近年、 Google フォーム, Zoho Creator, Questant などオンライン上で、入力フォームを自由に設定して、そのまま運用ができるようなサービスが増えています。

このような仕組みを自分の担当システムに組み込むことができれば、「もう仕様変更も怖くない!」となりますが、通常Railsアプリケーションでは入力フォームは DBのテーブル・Modelの定義が静的なため、簡単には行きません。

本稿では、Modelに動的な attrの定義するのを諦めて、動的にModel生成することで、この問題を回避しています。

ソース一式はgithubに設置しております。
https://github.com/YoshikazuOota/dynamic_form

dynamic_form_image.png

概要

scaffold で作られる入力系は 図1のような関係を持っています。
(系 :ここでは ビュー、コントローラ、モデルのセットを指します)

本稿では、図2 の用に通常のモデルを 3つに分解します

  • input_model.rb : コントローラ・ビューで直接使用するモデル。attrの定義・バリデーションの定義を担当する
  • value.rb : 入力フォームへの記入内容の保存を担当する。入力欄は動的に増えるため垂直分割的に分けて保存する
  • submit.rb : 入力内容をまとめる役割をする

input_model.rb で本稿の肝となる動的なモデル作成を行っております。

scaffold.png dyn_form.png

入力データの保存は submitモデル と valueモデルに分けて保存します。
一般的なテーブルへの保存と、本稿の保存方法を比較すると図3の用になります

data_store.png

新規作成・保存の流れ

新規作成をする場合を例にとって説明を行います

  1. form_forに渡すモデル @inputを生成(submit_controller.rb の new をコールして)
  2. submits/new.html.erb でレンダリング
  3. submit_controller.rb の create を呼んでバリデーション・保存

1. form_forに渡すモデル @inputを生成

パラメータ(入力フィールドの種類・バリデーション)を与えて、
input_model.rb の モジュールで form_forに渡すためのモデルを作成します。

パラメータ(入力項目設定)

パラメータは items テーブルに格納され Item モデルを介してアクセスします。
本投稿では入力フォームの定義(= itemsテーブルの内容)は下記の状態として説明を行います。

id | name | typ | presence | only_integer | format_with
----+-----------+-----+----------+--------------+-----------
1 | 文字列入力 | 1 | 0 | 0 |
2 | 数字 | 2 | 1 | 0 |
3 | 日時 | 3 | 1 | 0 |
4 | アルファベットのみ| 1 |1 |0 | \A[a-zA-Z]+\z

  • name: 入力項目の見出し
  • typ: データの型(TYP_STING = 1, TYP_NUMBER = 2, TYP_DATETIME = 3)
  • presence: 必須入力バリデーションフラグ
  • only_integer: 整数チェックバリデーションフラグ
  • format_with: 正規表現バリデーション用文字列

このテーブルに新しい行を追加・削除することでに動的に入力項目が変わります

動的モデル生成処理

動的モデルの生成は下記の処理で取得します。
引数は上記のテーブルを参照するItemモデルの配列となります。

  def new
    @input = InputModel.create.new(Item.all)
  end

input_model.rb の生成処理は下記の用になります

module InputModel
  extend ActiveSupport::Concern

  included do
    scope :disabled, -> { where(disabled: true) }
  end

  def self.create
    # ▼▼▼ 入力用のクラスを動的生成 ▼▼▼
    active_model = Class.new do |_klass|
      include ActiveModel::Model
      include ActiveModel::Conversion
      include ActiveModel::AttributeMethods
      include ActiveRecord::Callbacks

      attr_accessor :items

      def initialize(items)
        @items = items
        items.each do |item|
          # アクセッサ定義
          attr_name = "item_#{item.id}"
          singleton_class.class_eval { attr_accessor attr_name }

          # バリデーション定義
          singleton_class.class_eval { validates attr_name, presence: item.presence} if item.presence
          singleton_class.class_eval { validates attr_name, numericality: { only_integer: item.only_integer}} if item.only_integer
          singleton_class.class_eval { validates attr_name, format: { with: Regexp.new(item.format_with)}, allow_blank: true } if item.format_with
        end
      end

      # attributes= の代わり
      def input_attributes=(hash)
        hash.each do |key, value|
          self.send("#{key}=", ERB::Util.html_escape(value))
        end
      end

      # 入力内容を保存
      def save
        ActiveRecord::Base.transaction do
          submit = Submit.create
          self.items.each do |item|
            val = Value.new({submit_id: submit.id, item_id: item.id})
            attr_name = "item_#{item.id}"

            # 入力データの型に合わせてデータ保存
            case item.typ
              when Item::TYP_STING
                val.str = self.send(attr_name)
              when Item::TYP_NUMBER
                val.int = self.send(attr_name)
              when Item::TYP_DATETIME
                val.datetime = self.send(attr_name)
            end
            val.save
          end
        end
      end
    end

    # ▲▲▲ 入力用のクラスを動的生成 ▲▲▲
    Object.const_set(:Input, active_model)
  end
end

上記のパラメータの内容を与えた場合、以下のような定義をしたものと同等となります(input_attributes, saveは別として)

class Item < ApplicationRecord
  attr_accessor :item_1

  attr_accessor :item_2
  validates :item_2, presence: true
  validates :item_2, numericality: { only_integer: true}

  attr_accessor :item_3
  validates :item_3, presence: true

  attr_accessor :item_4
  validates :item_4, presence: true
  validates :item_4, format: { with: /\A[a-zA-Z]+\z/ }, allow_blank: true
end

2. submits/new.html.erb でレンダリング

form_forに与えるモデルには、 item_1, item_2 .. とアクセッサが定義されていますが、
それぞれデータ型やフォームが異なる場合があるため、条件分けをしています

submits/new.html.erb (*見通しを良くするため Bootstrap等のclassを削除しています)

<%
    # (ここは動的なフォーム作成とは直接関係しない)
    # 新規作成・修正更新でテンプレートを共通化するため URL, Methodを適宜修正
    if @submit_id
      url = {action: :update, id: @submit_id}
      method = :patch
    else
      url = {action: :create}
      method = :post
    end
%>

<%= form_for @input, url: url, method: method do |f| %>
    <% @input.items.each do |item| %>
        <div>
            <div>
                <b><%= item.send("name") %></b>
            </div>
            <div>
                <% case item.typ %>
                <% when Item::TYP_STING %>
                    <%= f.text_field "item_#{item.id}" %>
                <% when Item::TYP_NUMBER %>
                    <%= f.number_field "item_#{item.id}" %>
                <% when Item::TYP_DATETIME %>
                    <%= f.datetime_field "item_#{item.id}", data: {:date_format => 'YYYY/MM/DD hh:mm'} %>
                <% end %>
            </div>
            <div>
                <% if item.presence %>
                    <%= content_tag(:span, glyph(:exclamation_sign) + " 必須") %>
                <% end %>
                <%= content_tag(:span, f.errors_for("item_#{item.id}"), {class: 'text-danger'}) if f.errors_on?("item_#{item.id}") %>
            </div>
        </div>
    <% end %>
    <div>
        <div>
            <%= f.submit '保存' %>
        </div>
    </div>
<% end %>

3. submit_controller.rb の create を呼んでバリデーション・保存

ビューに入力情報をStrong Parameters 経由でモデルにセットして、バリデーションと保存を行います

  def create
    @input = InputModel.create.new(Item.all)
    @input.input_attributes = submit_params

    if @input.validate && @input.save
      redirect_to submits_path and return
    else
      render :new
    end
  end

  # strong parameters
  def submit_params
    permit_attrs = []
    Item.all.each do |item|
      permit_attrs.push("item_#{item.id}".to_sym)
    end
    params.require(:input).permit(permit_attrs)
  end

これにて、新規作成の完了です。
編集更新・内容表示については、ソースをご参照ください。

デモプログラムの実行

Rails本体を含めて、githubにおいてあります。
https://github.com/YoshikazuOota/dynamic_form

サンプルデータがseedsにはいっておりますので、利用する場合はdb:seedを実行してください

rails db:seed

デモ実行コマンド例 

git clone git@github.com:YoshikazuOota/dynamic_form.git
【mysqlに 'dynamic_form_development'を追加。 DB User等を設定】
bundle install
rails db:seed
rails s

URL

入力項目編集画面

入力画面

* 説明が至らない点等ございましたら、ご気軽にご指摘ください!
ご返答させていただきたく思います

74
64
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
74
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?