В предыдущей части я рассказал про контроллеры и роутинг. Теперь поговорим про формы. Довольно часто требуется реализовать формы, которым не соответствует ни одна модель. Или добавить валидацию, которая имеет смысл только в конкретном бизнес-процессе.
Я расскажу про 2 типа форм: form-objects и types.
Объкты-формы используются для обработи и валидации пользовательского ввода, когда данные нужны для какой-либо операции. Например, вход пользователя в систему или фильтрация данных.
Types используются, если нужно расширить поведение модели. Например, в вашем проекте пользователи могут регистрироваться как через vkontakte, так и через обычную форму. Заполнение email обязательно для обычных пользователей, а для vk пользователей — нет. Такое поведение легко решается с помощью types.
Form-objects
В RoR проектах формы жестко завязаны на модели. Отрендерить сложную форму без объекта-модели практически невозможно да и не удобно. Поэтому объекты-формы расширяются с помощью ActiveModel::Model. Таким образом, формы — это модели без поддержки persistence (не сохраняются в БД). Соответственно, мы получим бесшовную интеграцию с билдером форм, валидации, локализацию.
Для удобства работы объекты-формы также используют gem virtus. Он берет на себя приведение типов, выставляет значения по умолчанию. Например, если из формы приходит дата в строковом представлении, то virtus автоматически преобразует ее в дату.
# Базовый класс для всех форм
# app/forms/base_form.rb
class BaseForm
include Virtus.model(strict: true)
include ActiveModel::Model
end
# app/forms/user/statistics_filter_form.rb
class User::StatisticsFilterForm < BaseForm
attribute :start_date, ActiveSupport::TimeWithZone, default: ->(*) { DateTime.current.beginning_of_month }
attribute :end_date, ActiveSupport::TimeWithZone, default: ->(model, _) { model.start_date.next_month }
end
# app/controllers/web/users/statistics_controller.rb
class Web::Users::StatisticsController < Web::Users::ApplicationController
def show
# тут не обязательно использовать permits, т.к. это актуально только для active_record моделей
@filter_form = User::StatisticsFilterForm.new params[:user_statistics_filter_form]
@statistics = UserStatisticsQuery.perform resource_user, @filter_form.start_date, @filter_form.end_date
end
end
= simple_form_for @filter_form, method: :get, url: {} do |f|
= f.input :start_date, as: :datetime_picker
= f.input :end_date, as: :datetime_picker
= f.button :submit
Рассмотрим ситуацию посложнее. У нас есть форма входа в систему с двумя полями: email и password. Поля обязательны для заполнения. Также если пользователь не найден или пароль не подошел, должна выводиться соответствующая ошибка.
# app/forms/session_form.rb
class SessionForm < BaseForm
attribute :email
attribute :password
validates :email, email: true
validates :password, presence: true
# добавляем валидацию для случая, если пользователь не найден или пароль не подошел
validate do
errors.add(:base, :wrong_email_or_password) unless user.try(:authenticate, password)
end
def user
@user ||= User.find_by email: email
end
end
# app/controllers/web/sessions_controller.rb
class Web::SessionsController < Web::ApplicationController
def new
@session_form = SessionForm.new
end
def create
@session_form = SessionForm.new session_form_params
# форма берет на себя всю валидацию
if @session_form.valid?
sign_in @session_form.user
redirect_to root_path
else
render :new
end
end
private
def session_form_params
params.require(:session_form).permit(:email, :password)
end
end
В этом примере форма берет на себя все заботы о валидации входных данных, контроллер не содержит лишней логики, а модель только проверяет пароль.
С таким подходом очень просто реалиовать дополнительный функционал: вывести чекбокс "запомнить меня", блокировать пользователей из черного списка.
Types
Если я не ошибаюсь, Types пришли из symfony. Type — это наследник модели, который выдает себя за родителя и добавляет новый функционал.
Рассмотрим такую задачу: пользователи приложения могут приглашать пользователей только рангом ниже себя. Также приглашающий не должен знать пароль приглашаемого. Список ролей пользователей, которые можно назначить новому пользователю определяются политикой. В части про ACL я подробнее об этом расскажу.
module BaseType
extend ActiveSupport::Concern
class_methods do
def model_name
superclass.model_name
end
end
end
class InviteType < User
include BaseType
after_initialize :generate_password, if: :new_record?
validates :role, inclusion: { in: :available_roles }
validates :inviter, presence: true # приглашающий
def policy
InvitePolicy.new(inviter, self)
end
def available_roles
policy.available_roles
end
def available_role_options
User.role.options.select{ |option| option.last.in? available_roles }
end
private
def generate_password
self.password = SecureRandom.urlsafe_base64(6)
end
end
InviteType проверяет наличие приглашающего, генерирует пароль и ограничивает список доступных ролей.
Подробнее остановлюсь на BaseType. Он переопределяет метод model_name, чтобы type воспринимался как родительский объект. Не стоит переопределять метод name, т.к. ruby из-за этого сносит крышу. Тут есть тонкость при работе с STI: нужно дополнительно переопределить метод sti_name.
Имея объекты-формы и types удобно трансформировать данные, поступающие из формы. Например, в форме есть 2 поля: затраченно часов, затрачено минут, а модель хранит затраченное время в секундах.
class CommentType < Comment
include BaseType
# some code
def elapsed_time_hours
TimeConverter.convert_to_time(elapsed_time.to_i)[:hours]
end
def elapsed_time_hours=(v)
update_elapsed_time v.to_i, elapsed_time_minutes
end
def elapsed_time_minutes
TimeConverter.convert_to_time(elapsed_time.to_i)[:minutes]
end
def elapsed_time_minutes=(v)
update_elapsed_time elapsed_time_hours, v.to_i
end
private
def update_elapsed_time(hours, minutes)
self.elapsed_time = TimeConverter.convert_to_seconds(hours: hours, minutes: minutes)
end
end
ПС. Статья была написана больше года назад и просто пролежала в архиве. За это время я выложил в общий доступ проект, на основе которого писался этот цикл статей. Некоторые вещи я бы сделал иначе, тем не менее в нем есть интересные и актуальные решения.