Rails: DRY style ajax validation

  • Tutorial
When I was just starting to think about joining the world of web development and choosing the language I would like to start with, one of the wikipedias told me that Rails philosophy is based on 2 principles: Convention over configuration (CoC) and Don’t Repeat Yourself (DRY) . As for the first - I just didn’t understand what it was about, but the second understood, accepted and expected that in the bowels of this wonderful framework, I will find a native tool that allows me to write once the validation rules for model attributes, and then use these rules for both front and back checks.

As it turned out later, I was disappointed. There is no such thing in the rails from the box, and all that was possible to find on the topic during the training is railscast about client_side_validations gem.

At that time, I only knew about javascript that it is, so I had to silently fasten the gem to the emerging blog and postpone the topic of dry validations to a closer acquaintance with js. And now this time has come: I needed a flexible tool for checking forms, and validates_inclusion_ofI did not intend to rewrite each one in js-style. And the gem is no longer supported.

Formulation of the problem


Find a way that will allow:
  1. when validating attributes use one logic: both for the back and for the front
  2. quickly "hang up" checks on different forms and flexibly configure them (both logic and visual)

The solution is materialized in a small demo: http://sandbox.alexfedoseev.com/dry-validation/showoff

And a couple of explanatory paragraphs below.

Instruments


I forgot to mention that I'm moderately lazy, and to write my own js-validator was not originally included in my plans. From ready-made solutions, my choice fell on jQuery Validation Plugin .

It can simply be thrown into js-assets or put as a gem .
Nothing else is required.

I will carry the good light through an example. Suppose we have a mailing list that stores email addresses and sets the frequency of mailing for each address (how many times a week we write a letter).

Let's get to the point


Accordingly, there is a model - Email
And its two attributes:
  • email - email address
  • frequency - the frequency with which the newsletter will go to this address

What will be the restrictions:
  • availability emailrequired
  • email must be unique
  • email must be email (with a dog and other things)
  • frequencyoptional, but if any, it should be in the range of 1 to 7


We implement:

app / models / email.rb

class Email < ActiveRecord::Base
  before_save { self.email = email.downcase }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true,
            uniqueness: { case_sensitive: false },
            format: { with: VALID_EMAIL_REGEX }
  validates_inclusion_of :frequency, in: 1..7, allow_blank: true
end


In the controller and view, everything is absolutely standard
app / controllers / emails_controller.rb

class EmailsController < ApplicationController
  def new
    @email = Email.new
  end
  def create
    @email = Email.new(email_params)
    if @email.save
      flash[:success] = 'Email добавлен!'
      redirect_to emails_url
    else
      render :new
    end
  end
  private
    def email_params
      params.require(:email).permit(:email, :frequency)
    end
end


app / views / emails / new.html.haml

%h1 Новая почта
= form_for @email do |f|
  = render partial: 'shared/error_messages', locals: { object: f.object }
  %p= f.text_field :email, placeholder: 'Почта'
  %p= f.text_field :frequency, placeholder: 'Периодичность рассылки'
  %p= f.submit 'Добавить!'



The next step is to hang the validator on the form and see what's what.
This is done simply:$('#form').validate();
I’ll repeat the link to the documentation for the plugin so that it no longer returns. There is a small problem with the structured content, but there is all the information.

So, hang up:

app / assets / javascripts / emails.js.coffee

jQuery ->
  validate_url = '/emails/validate'
  $('#new_email, [id^=edit_email_]').validate(
    debug: true
    rules:
      'email[email]':
        required: true
        remote:
          url: validate_url
          type: 'post'
      'email[frequency]':
        remote:
          url: validate_url
          type: 'post'
  )


Let's go through each line:
  • validate_url = '/emails/validate'
    the address to which we will send the ajax request to check the field values
  • $('#new_email, [id^=edit_email_]').validate
    we hang the validator both on the form of a new email and on the form of editing existing addresses
  • debug: true
    the method disables the form submission so that you can have fun with the settings
  • rules:
    the verification rules for fields are written in the method, the plugin has a lot of them out of the box (see docks), but so far we are only interested in 2
  • 'email[email]':
    name – attribute of the form field (simple names are indicated without quotes, with special characters - we take in quotes)

The following two methods will dwell in more detail.

remote

remote:
  url: validate_url
  type: 'post'

First, let's talk about the main method of this post - remote. With it, we can send ajax requests to the server and process the returned data.

How it works: the method needs to feed the request url and its type (in our case, send the post-request). This is enough to send the field value for verification to the server.

In response, the method expects to receive json:
  • the answer truemeans that everything is ok with the field
  • answers false, undefinedor null, as well as any other, string'аare regarded by the method as a signal of failed validation


required

required: true

The method of "required fields". The only check that you can’t (and don’t) need to be done through a server call is validates_presence_of(that is, availability validation). This is due to the features of the validator - it pulls the method remoteonly if any data was entered in the field. "Run with your hands" this check is impossible, therefore, the validation of the presence is prescribed directly through this method. By the way, it takes functions as an argument, so complex logical checks for availability can (and should) be done through it.

Continue


The validator is hung up, the ajax request goes to the server, what's next:
  • you need to create a method in the controller that will handle the request
  • register a route to this method


app / controllers / emails_controller.rb

  def validate
    # пока пустой
  end


config / routes.rb

resources :emails
post 'emails/validate', to: 'emails#validate', as: :emails_validation


Great, now the server can accept post-requests to the address. '/emails/validate'
Let's start the server, open the creation form Emailin the browser (lvh.mehaps000/emails/new), type “something” in the form field and run to the console - see what it sends us a validator.

In general, this could be expected:

Started POST "/emails/validate" for 127.0.0.1 at 2014-02-17 22:10:31 +0000
Processing by EmailsController#validate as JSON
  Parameters: {"email"=>{"frequency"=>"что-нибудь"}}

Now about the strategy: what will we do with this good - how to process and what to return:
  • from arriving at the controller json, we will create a new object in memoryEmail
  • and pull its validation through ActiveModel
  • an object of the class ActiveModel::Errors(accessible via the method errors) will be drawn in memory , in which there will be a hash @messageseither with errors (if the attributes did not pass validation) or empty (if everything is fine with the object)
  • we will analyze this hash and, if it is empty, we will respond to the browser true, and if it has errors for the attribute being checked, we will respond with the text of these errors, which will be regarded by the receiving method of the plug-in as a failed validation. And, moreover, the plugin uses the string received as the text of the error message.

Chocolate! Not only are validation rules written once directly in the model, but also error messages are stored directly in the rail locale.

By the way, let's write them.

config / locales / ru.yml

ru:
  activerecord:
    attributes:
      email:
        email: "Почта"
        frequency: "Периодичность"
    errors:
      models:
        email:
          attributes:
            email:
              blank: "обязательна"
              taken: "уже добавлена в список рассылки"
              invalid: "имеет странный формат"
            frequency:
              inclusion: "должна быть в диапазоне от 1 до 7 включительно"

We read about I18n in the Rails guides: http://guides.rubyonrails.org/i18n.html

Attribute names and messages are spelled out.
Now the most interesting part is creating a response to the browser.

I immediately dump the working code, which we will parse into the lines:

app / controllers / emails_controller.rb

def validate
  email = Email.new(email_params)
  email.valid?
  field = params[:email].first[0]
  @errors = email.errors[field]
  if @errors.empty?
    @errors = true
  else
    name = t("activerecord.attributes.email.#{field}")
    @errors.map! { |e| "#{name} #{e}
" } end respond_to do |format| format.json { render json: @errors } end end


Go.

email = Email.new(email_params)
email.valid?

We create an object in memory from the parameters that arrived from the form and pull the validation check so that the object appears in memory ActiveModel::Errors. In the @messageserror hash , in addition to the ones we need for the checked attribute, there will be messages for all other attributes (since the values ​​of all the others are nil, only the value of the checked attribute arrived).

Let's see how an object looks to understand how to parse it:

(rdb:938) email.errors
#=> #, @messages={:email=>["обязательна", "имеет странный формат"], :frequency=>["должна быть в диапазоне от 1 до 7 включительно"]}>

We see a hash with error messages, and the docks tell us how to get them :

(rdb:938) email.errors['frequency']
#=> ["должна быть в диапазоне от 1 до 7 включительно"]

That is, in order to get errors for an attribute, we first need to get the name of this attribute.
This we will pull from the hash params:

# так выглядит хэш
(rdb:938) params
#=> {"email"=>{"frequency"=>"что-нибудь"}, "controller"=>"emails", "action"=>"validate"}
# нам известно название модели, поэтому достаём атрибуты
(rdb:938) params[:email]
#=> {"frequency"=>"что-нибудь"}
# поскольку за запрос улетает всегда один атрибут, то забираем первый
(rdb:938) params[:email].first
#=> ["frequency", "что-нибудь"]
# на первом месте всегда будет ключ, то есть имя атрибута -> забираем
(rdb:938) params[:email].first[0]
#=> "frequency"

We return to the validation function in the controller:

field = params[:email].first[0]
@errors = email.errors[field]

First, we got the name of the checked attribute of the model, then we pulled out an array with error messages.

After that, we will formulate a response to the browser:

if @errors.empty?
  @errors = true
else
  name = t("activerecord.attributes.email.#{field}")
  @errors.map! { |e| "#{name} #{e}
" } end

If the array with errors is empty, then the variable @errorsis this true(it is this answer that the plugin expects if there are no errors).

If there are errors in the array, then:
  • if we give it away just like that @errors, we get a message “should be in the range from 1 to 7 inclusive” (and if there are several, they just “stick together” at the output)

That's why we:
  • pull the model attribute name from the rail localization file:
    name = t("activerecord.attributes.email.#{field}")
  • and in each element of the array with errors, add this name to the beginning with a space:
    @errors.map! { |e| "#{name} #{e}
    " }
  • you can also brstick at the end of each error, it depends on the layout, in short add to taste

As a result, we
get an array with messages in the format: "The frequency should be in the range from 1 to 7 inclusive . "

And the last touch - we pack it all in json:

respond_to do |format|
  format.json { render json: @errors }
end

Rails responds to the browser.

Refactor


For one model, this works, but we will have many models in the application. In order to not-repeat-yourself, you can rewrite the routing and validation method in the controller.

Routing

config / routes.rb

# было
post 'emails/validate', to: 'emails#validate', as: :emails_validation
# чтобы не переписывать маршрут для каждого контроллера
post ':controller/validate', action: 'validate', as: :validate_form


Validation method

We will take out the validation logic in application_controller.rb so that it can be used by any application controllers.

app / controllers / application_controller.rb

def validator(object)
  object.valid?
  model = object.class.name.underscore.to_sym
  field = params[model].first[0]
  @errors = object.errors[field]
  if @errors.empty?
    @errors = true
  else
    name = t("activerecord.attributes.#{model}.#{field}")
    @errors.map! { |e| "#{name} #{e}
" } end end


app / controllers / emails_controller.rb

def validate
  email = Email.new(email_params)
  validator(email)
  respond_to do |format|
    format.json { render json: @errors }
  end
end

PS In order not to jerk the server with each character entered by the user in the form fields, set the value of the onkeyup: false

jQuery Validation Plugin method :
http://jqueryvalidation.org

Demo with bows:
http://sandbox.alexfedoseev.com/dry-validation/showoff

UPDATE: Edited heading

Also popular now: