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

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


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

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

    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.