January 7, 2020

Create a contact form using ActiveModel, I18n and conditional validations

Build a Ruby on Rails contact form with ActiveModel, I18n for custom translations and conditional validations.

Hey! I just met you and this is crazy, but here's my form, so contact me maybe...

Getting in touch through a website contact form is commonplace for a lot of web applications. Typically, a user may want to send comments or feedback through a form to allow them to receive a more detailed response. Therefore, we will be building a contact form today that accepts forename, surname, phone number, email address and a message. Our contact form is going to be for a website asking for feedback or messages.

Here's my application

It's likely you will be adding this to a website with a database at some point, but if you're along for the ride, let's create a new application for testing.

We're not going to be using a database for our test application, so let's omit it entirely:

rails new call_me_maybe --skip-active-record

It's no fun having to send lots of emails while we're testing, so we're going to add the letter_opener gem to our Gemfile and install.

As you can see below, I've added it to the development group as we're not going to want this installed in any other environment:

group :development do
  # Access an interactive console on exception pages or by calling 'console' anywhere in the code.
  gem 'web-console', '>= 3.3.0'
  # Add letter_opener here
  gem 'letter_opener'
end

Then, run:

bundle install

There's just one more step to get Letter Opener up and running, and that's to configure it within the development environment file: edit config/environments/development.rb and add:

# Settings specified here will take precedence over those in config/application.rb.
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true

Building a contact form

The next step is to build the form to allow users to enter their information, this is where we will decide on our fields:

  • forename (presented as 'First name' to the user)
  • surname (presented as 'Last name' to the user)
  • phone_number (presented as 'Mobile phone number' to the user)
  • email_address (presented as 'Email address' to the user)
  • message (presented as 'Please enter your feedback or comments')

That's our fields decided, now to build the form.

To scaffold or not to scaffold?

Personally, I don't use scaffolds very often at all as I find they often add more than I need and so I remove a lot of the files it generates.

However, with that said, we're going to create a contact scaffold so that it can generate the files, including the form, for us, along with inserting the necessary routes, but without additional information such as helpers, stylesheets and jbuilder.

rails g scaffold Contact forename surname phone_number email_address message:text --no-jbuilder --no-helper --no-scaffold-stylesheet

If this has worked, you will see a list:

invoke  resource_route
route    resources :contacts
invoke  scaffold_controller
create    app/controllers/contacts_controller.rb
invoke    erb
create      app/views/contacts
create      app/views/contacts/index.html.erb
create      app/views/contacts/edit.html.erb
create      app/views/contacts/show.html.erb
create      app/views/contacts/new.html.erb
create      app/views/contacts/_form.html.erb
invoke    test_unit
create      test/controllers/contacts_controller_test.rb
create      test/system/contacts_test.rb
invoke  assets
invoke    scss
create      app/assets/stylesheets/contacts.scss

This has generated:

  • the necessary declaration in config/routes.rb
  • the necessary controller
  • the necessary files in app/views/contacts
  • test files
  • a stylesheet

For now, our focus is on restricting these routes and only allowing new and create.

Tidying up

As we know that we will be allowing the new and create routes, we need to amend that in config/routes.rb:

# frozen_string_literal: true

Rails.application.routes.draw do
  resources :contacts, only: %i[new create]
end

Now that we've removed access to all routes except new and create, we need to remove the files; let's remove:

  • app/views/contacts/index.html
  • app/views/contacts/edit.html
  • app/views/contacts/show.html

These are no long necessary nor accessible through our application.

This keeps our application clean and tidy as it means all the files within it are relevant. You may have also already guessed that the controller needs to be updated.

Navigate to app/controllers/contacts_controller.rb and remove all the actions except new and create, but keep the 'contact_params' method.

You should end up with something like the following:

# frozen_string_literal: true

class ContactsController < ApplicationController
  # GET /contacts/new
  def new
    @contact = Contact.new
  end

  # POST /contacts
  # POST /contacts.json
  def create
    @contact = Contact.new(contact_params)

    respond_to do |format|
      if @contact.save
        format.html { redirect_to @contact, notice: 'Contact was successfully created.' }
        format.json { render :show, status: :created, location: @contact }
      else
        format.html { render :new }
        format.json { render json: @contact.errors, status: :unprocessable_entity }
      end
    end
  end

  private

  # Never trust parameters from the scary internet, only allow the white list through.
  def contact_params
    params.require(:contact).permit(:forename, :surname, :phone_number, :email_address, :message)
  end
end

Creating our model

As we created our application without ActiveRecord, our model doesn't get generated part of the scaffold. This is expected. It will likely have been created if you have a database assigned to your application.

However, either way, you will need to create, or open, app/models/contact.rb and ensure that you have the following:

# frozen_string_literal: true

#
# Contact form handling
#
class Contact
  include ActiveModel::Model

  attr_accessor :forename,
              :surname,
              :email_address,
              :phone_number,
              :message
end

Ready? Set? Email!

With all of our preparations so far, we should now have a basic form accessible through our application with the necessary fields; let's boot up our server and check we can access what we need.

bundle exec rails server

Then navigate to: http://localhost:3000/contacts/new.

You should see a form, which is correct. We now need to make it output the correct labels for our fields.

Customising I18n for ActiveModel

By default, Ruby on Rails uses 'en' as its default locale. This can be changed depending on the application - it's therefore important to include the correct translations in the correct files. We will continue to use config/locales/en.yml for our application.

The locale file is a YAML file and Rails' internal code looks for a specific format, depending on what's being used. As we're using ActiveRecord, not ActiveModel (which is what you would be using if you were saving this to a database), we need to use the following:

en:
  hello: "Hello world"
  activemodel:
    attributes:
      contact:
        forename: First name
        surname: Last name
        phone_number: Mobile phone number
        message: Please enter your feedback or comments

You will see the format is 'activemodel.attributes.model_name.attribute_name'.

Update your locale file to match the above and double check to make sure your fields have updated. A page refresh should do it.

Creating our mailer

The next step is to close our server temporarily so that we can generate a mailer. Press CTRL + C to close your server.

As it's good to keep things consistent, our contact mailer is simply going to be ContactMailer with one method of 'new_contact_form_submission', which will handle the sending of emails if the form validates.

Let's generate a mailer:

rails g mailer Contact new_contact_form_submission

This will create files:

create  app/mailers/contact_mailer.rb
invoke  erb
create    app/views/contact_mailer
create    app/views/contact_mailer/new_contact_form_submission.text.erb
create    app/views/contact_mailer/new_contact_form_submission.html.erb
invoke  test_unit
create    test/mailers/contact_mailer_test.rb
create    test/mailers/previews/contact_mailer_preview.rb
Customising our mailer

Let's head over to app/mailers/contact_mailer.rb.

You should have something such as:

class ContactMailer < ApplicationMailer

  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.contact_mailer.new_contact_form_submission.subject
  #
  def new_contact_form_submission
    @greeting = "Hi"

    mail to: "[email protected]"
  end
end

There are a few changes we need to make to this mailer; firstly, we need to allow to receive the contact object and also create a contact instance variable accessible within the view.

def new_contact_form_submission(contact)
  @contact = contact

  mail to: "[email protected]"
end

You should also customise the 'to' email address, but for this tutorial, I'm going to leave it as it is.

As you can also see, this mailer supports I18n too; so, let's customise our subject by following the pattern it provides. Your config/locales/en.yml file may look something like:

en:
  hello: "Hello world"
  activemodel:
    attributes:
      contact:
        forename: First name
        surname: Last name
        phone_number: Mobile phone number
        message: Please enter your feedback or comments
  contact_mailer:
    new_contact_form_submission:
      subject: Contact form has been submitted
Formatting our email

Head over to app/views/contact_mailer/new_contact_form_submission.text.erb. This is the format of the email if the email client is only accepting text format emails, so we shouldn't include any HTML here. We can access the contact object through the contact instance variable we set up earlier, so can output something as follows:

<%= Contact.human_attribute_name('forename') %>
<%= @contact.forename %>

<%= Contact.human_attribute_name('surname') %>
<%= @contact.surname %>

<%= Contact.human_attribute_name('phone_number') %>
<%= @contact.phone_number %>

<%= Contact.human_attribute_name('email_address') %>
<%= @contact.email_address %>

<%= Contact.human_attribute_name('message') %>
<%= @contact.message %>

This has the added benefit of also referencing our I18n translations for sending the email.

We should now do the same for the HTML version of the mailer in app/views/contact_mailer/new_contact_form_submission.html.erb, with the added benefit of now being able to use HTML:

<p>
  <strong><%= Contact.human_attribute_name('forename') %></strong><br />
  <%= @contact.forename %>
</p>

<p>
  <strong><%= Contact.human_attribute_name('surname') %></strong><br />
  <%= @contact.surname %>
</p>

<% if @contact.phone_number.present? %>
  <p>
    <strong><%= Contact.human_attribute_name('phone_number') %></strong><br />
    <%= @contact.phone_number %>
  </p>
<% end %>

<% if @contact.email_address.present? %>
  <p>
    <strong><%= Contact.human_attribute_name('email_address') %></strong><br />
    <%= mail_to @contact.email_address %>
  </p>
<% end %>

<p>
  <strong><%= Contact.human_attribute_name('message') %></strong><br />
  <%= simple_format @contact.message %>
</p>

I've included the 'simple_format' helper to ensure that any line breaks are correctly formatted within the email.

Setting our validations

So far, we have laid the groundwork to enable the processing but at present, a user can simply submit a blank form. This isn't ideal and we should change this.

Let's head back over to app/models/contact.rb and add in some validates. We're going to make forename and surname valid, require either an email address or phone number and a message.

Requiring the presence of a field is easy, you simply use the PresenceValidator as follows:

validates :forename, presence: true
validates :surname, presence: true
validates :message, presence: true

However, conditional checking as to whether one field or another is available and presenting a message if not requires a method to be defined.

Firstly, we define a method to be called by our validator and a condition for it to run on, then ensure that it's called as part of the validation process.

validate :email_address_or_phone_number, unless: :email_address_or_phone_number?

def email_address_or_phone_number?
  email_address.present? || phone_number.present?
end

def email_address_or_phone_number
  errors.add(:phone_number, I18n.t('errors.messages.contact.phone_number.blank'))
end

Above, you will see the 'email_address_or_phone_number' method is called unless 'email_address_or_phone_number?' returns true.

The 'email_address_or_phone_number?' method checks to see if either an email address or phone number is present.

The 'email_address_or_phone_number' adds an error to our phone number to request it, unless an email address is provided. This references our I18n locale file, so we need to update that accordingly, as follows:

en:
  hello: "Hello world"
  activemodel:
    attributes:
      contact:
        forename: First name
        surname: Last name
        phone_number: Mobile phone number
        message: Please enter your feedback or comments
  contact_mailer:
    new_contact_form_submission:
      subject: Contact form has been submitted
  errors:
    messages:
      contact:
        phone_number:
          blank: can't be blank, unless an email address is provided

This sets up validation to ensure that a form must include some data to be submitted.

Updating our controller to do something

Let's now update our controller to do something when the form has been submitted. It will currently error as it's expecting the model to save the data, but we don't want to do that.

As we're using ActiveModel, we will simply check to see if our model validates by using 'valid?'. Replace:

@contact.save

to become:

@contact.valid?

Similarly, we're not expecting this form to deal with JSON requests, so let's strip that out of the controller too.

Our controller should now be a lot leaner:

class ContactsController < ApplicationController
  # GET /contacts/new
  def new
    @contact = Contact.new
  end

  # POST /contacts
  # POST /contacts.json
  def create
    @contact = Contact.new(contact_params)

    if @contact.valid?
      redirect_to @contact, notice: 'Contact was successfully created.'
    else
      render :new
    end
  end

  private

    # Never trust parameters from the scary internet, only allow the white list through.
    def contact_params
      params.require(:contact).permit(:forename, :surname, :phone_number, :email_address, :message)
    end
end

You will note that the mailer isn't called yet, so let's add that in just below the '@contact.valid?' line:

class ContactsController < ApplicationController
  # GET /contacts/new
  def new
    @contact = Contact.new
  end

  # POST /contacts
  # POST /contacts.json
  def create
    @contact = Contact.new(contact_params)

    if @contact.valid?
      ContactMailer.new_contact_form_submission(@contact).deliver_now
      redirect_to @contact, notice: 'Contact was successfully created.'
    else
      render :new
    end
  end

  private

    # Never trust parameters from the scary internet, only allow the white list through.
    def contact_params
      params.require(:contact).permit(:forename, :surname, :phone_number, :email_address, :message)
    end
end

This is all great, except it will cause an error on submission. Have you noticed what the error could be?

It's redirecting to the '@contact' object, but there is no show page, so instead, we should redirect back to the new action.

class ContactsController < ApplicationController
  # GET /contacts/new
  def new
    @contact = Contact.new
  end

  # POST /contacts
  # POST /contacts.json
  def create
    @contact = Contact.new(contact_params)

    if @contact.valid?
      ContactMailer.new_contact_form_submission(@contact).deliver_now
      redirect_to ({ action: :new }), notice: 'Contact was successfully created.'
    else
      render :new
    end
  end

  private

    # Never trust parameters from the scary internet, only allow the white list through.
    def contact_params
      params.require(:contact).permit(:forename, :surname, :phone_number, :email_address, :message)
    end
end

Nearly there. There's just one more thing to do to ensure that our controller uses our I18n locale; the flash message. Let's update this to something defined in our en.yml.

My convention for this is 'controllers.controller_name.controller_action', so I've defined 'controllers.contacts.create'.

en:
  hello: "Hello world"
  activemodel:
    attributes:
      contact:
        forename: First name
        surname: Last name
        phone_number: Mobile phone number
        message: Please enter your feedback or comments
  contact_mailer:
    new_contact_form_submission:
      subject: Contact form has been submitted
  errors:
    messages:
      contact:
        phone_number:
          blank: can't be blank, unless an email address is provided
  controllers:
    contacts:
      create: 'Thanks for getting in touch.'

Now, we simply update our controller:

redirect_to ({ action: :new }), notice: I18n.t('controllers.contacts.create')

This concludes our set up, although for bonus points, you may want to edit app/views/contacts/_form.html.erb and assign the 'email_address' form fields to be an 'email_field'.

Flash output

To ensure that the flash message is displayed to the user, head over to app/views/contacts/new.html and add in the following just above the form render:

<% if flash.present? %>
  <% flash.each do |key, value| %>
    <%= tag.div value, class: "flash-message flash-message--#{key}" %>
  <% end %>
<% end %>

Submitting the form

Now you're all set, you can start your server then head over to http://localhost:3000/contacts/new and submit the form.

If all goes well, you should see a flash message with the text of 'Thanks for getting in touch' and Letter Opener should open a window with your email in.

If you see the flash message but not the email, you may want to open the tmp/letter_opener folder within your Rails application to see the files it has created. You can open these manually within a web browser.

In your production environment, I would recommend either SendGrid or Postmark.

Extra polish

Now that you've created your first contact form and have it submitting correctly, you may want to update the button from 'Create Contact' to 'Send email'. This is quite handily defined in your locale file too. Simply use the format 'helpers.submit.model_name.action' in this case, the action is create.

You should be able to append the following to your config/locales/en.yml file and see the button text update after a page refresh.

  helpers:
    submit:
      contact:
        create: Get in touch

Feedback? Comments? Contact me, maybe

I hope you enjoyed this tutorial and learned how to use I18n with ActiveModel and set up some custom validations based on conditionals. If you did, or didn't, please contact me either way and let me know what you think.