2016 Railsアドベントカレンダータイトル 「え? ActiveModelのバリデーション使いつつ、動的に入力フォーム定義する? できらぁ!」
背景
近年、 Google フォーム, Zoho Creator, Questant などオンライン上で、入力フォームを自由に設定して、そのまま運用ができるようなサービスが増えています。
このような仕組みを自分の担当システムに組み込むことができれば、「もう仕様変更も怖くない!」となりますが、通常Railsアプリケーションでは入力フォームは DBのテーブル・Modelの定義が静的なため、簡単には行きません。
本稿では、Modelに動的な attrの定義するのを諦めて、動的にModel生成することで、この問題を回避しています。
ソース一式はgithubに設置しております。
https://github.com/YoshikazuOota/dynamic_form
概要
scaffold で作られる入力系は 図1のような関係を持っています。
(系 :ここでは ビュー、コントローラ、モデルのセットを指します)
本稿では、図2 の用に通常のモデルを 3つに分解します
- input_model.rb : コントローラ・ビューで直接使用するモデル。attrの定義・バリデーションの定義を担当する
- value.rb : 入力フォームへの記入内容の保存を担当する。入力欄は動的に増えるため垂直分割的に分けて保存する
- submit.rb : 入力内容をまとめる役割をする
input_model.rb で本稿の肝となる動的なモデル作成を行っております。
入力データの保存は submitモデル と valueモデルに分けて保存します。
一般的なテーブルへの保存と、本稿の保存方法を比較すると図3の用になります
新規作成・保存の流れ
新規作成をする場合を例にとって説明を行います
- form_forに渡すモデル @inputを生成(submit_controller.rb の new をコールして)
- submits/new.html.erb でレンダリング
- 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
入力項目編集画面
入力画面
* 説明が至らない点等ございましたら、ご気軽にご指摘ください!
ご返答させていただきたく思います