Управление сложностью в проектах на ruby on rails. Часть 3

в 11:41, , рубрики: forms, ruby, ruby on rails

В предыдущей части я рассказал про контроллеры и роутинг. Теперь поговорим про формы. Довольно часто требуется реализовать формы, которым не соответствует ни одна модель. Или добавить валидацию, которая имеет смысл только в конкретном бизнес-процессе.

Я расскажу про 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

ПС. Статья была написана больше года назад и просто пролежала в архиве. За это время я выложил в общий доступ проект, на основе которого писался этот цикл статей. Некоторые вещи я бы сделал иначе, тем не менее в нем есть интересные и актуальные решения.

Автор: mkuzmin

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js