В предыдущей части я рассказал про контроллеры и роутинг. Теперь поговорим про формы. Довольно часто требуется реализовать формы, которым не соответствует ни одна модель. Или добавить валидацию, которая имеет смысл только в конкретном бизнес-процессе.
Я расскажу про 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
ПС. Статья была написана больше года назад и просто пролежала в архиве. За это время я выложил в общий доступ проект, на основе которого писался этот цикл статей. Некоторые вещи я бы сделал иначе, тем не менее в нем есть интересные и актуальные решения.
