Kevin Sylvestre

A Ruby and Swift developer and designer.

Augmenting a Ruby on Rails App with Vue.js

Building apps using standard Ruby on Rails templates is fantastic - for the most part. If one subscribes to the "Sprinkling of JavaScript" philosophy it is possible to build and iterate on features at a surprisingly quick tempo. If not - one still might think regular old Ruby on Rails forms are useful.

What happens when a custom form element is needed? How about integrating with a third party JavaScript SDK to handle an API call? What about happens 'Sprinkles' of JavaScript start to look more like 'Piles' and 'Mounds'? Using jQuery plus data attributes inevitably becomes unmanageable. Angular.js and Ember.js are nice - but they often seem all consuming. A perhaps overlooked alternative is Vue.js - a reactive and component driven framework that easily integrates with apps that serve side rendering.

To demonstrate why Vue.js is a great option for augmenting a mostly server side app this article tackles a problem that many (profitable) startups eventually need to solve: processing payments. For simplicity - this tutorial focuses on a part of that feature - securely collecting and tokenizing a credit card (via Stripe - although integrating with other payment gateways follows a similar workflow). Some knowledge of CoffeeScript is presumed - but the pattern does not require it.

Setting Up The App

To kick things off:

rails new sample
cd sample

Then the following dependencies can be added to the Gemfile:

Gemfile

source 'https://rails-assets.org' do
  gem 'rails-assets-vue'
  gem 'rails-assets-jquery.payment'
end
bundle

Next config/stripe.yml and config/initializers/stripe.rb can be setup to initialize Stripe (test keys can be used instead of ... - production keys should only be set via the ENV variables):

config/stripe.yml

defaults: &defaults
  publishable_key: <%= ENV['STRIPE_PUBLISHABLE_KEY'] || '...' %>
  secret_key: <%= ENV['STRIPE_SECRET_KEY'] || '...' %>

development:
  <<: *defaults

test:
  <<: *defaults

production:
  <<: *defaults

config/initializer/stripe.rb

config = YAML::load(ERB.new(File.read("#{Rails.root}/config/stripe.yml")).result)[Rails.env]
Rails.configuration.x.stripe.publishable_key = config['publishable_key']
Rails.configuration.x.stripe.secret_key = config['secret_key']

Stripe.api_key = Rails.configuration.x.stripe.secret_key

With the dependencies in place the application.js and application.css files can be modified to require all the basic dependencies of the sample app:

app/assets/javascripts/application.js

//= require turbolinks
//= require jquery
//= require jquery.payment
//= require jquery_ujs
//= require bootstrap
//= require vue
//= require ./main

app/assets/stylesheets/application.css

/**
 *= require bootstrap
 */

Then the the default layout can be modified. Notice the ordering of the includes / links, the meta tag, and the 'application' id attached to the html tag.

app/views/layouts/application.html.erb

<!DOCTYPE html>
<html id="application">
<head>
  <%= tag :meta, name: "stripe-publishable-key", content: Rails.configuration.x.stripe.publishable_key %>

  <%= javascript_include_tag "https://js.stripe.com/v2/" %>

  <%= javascript_include_tag "application", "data-turbolinks-track": "reload" %>
  <%= stylesheet_link_tag "application", "data-turbolinks-track": "reload" %>

  <%= csrf_meta_tags %>
</head>
<body>
  <div class="container">
    <%= yield %>
  </div>
</body>
</html>

Some configuration is needed. app/assets/javascripts/directives and app/assets/javascripts/components folders are required (they can be empty) then a main.coffee file handles the requires and configuring Vue.js and Stripe.js. Notice the el is the same id as specified in the layout and the stripe-publishable-key is taken from the meta tag.

app/assets/javascripts/main.coffee

#= require_tree ./components
#= require_tree ./directives

Stripe.setPublishableKey($('meta[name="stripe-publishable-key"]').attr('content'))
$(document).on "turbolinks:load", -> new Vue(el: "#application")

At this point the sample app should startup without any issues using rails server.

The Standard Rails Portion

A basic checkout model and controller are going to form the core of the application. The Checkout model takes in a source - a token that is collected by Stripe.js and applies a charge. A form is added for entering the number, cvc, and expiration of the card. This information is not sent to the server (instead a tokenized copy of the card is sent).

app/models/checkout.rb

class Checkout
  include ActiveModel::Model

  attr_accessor :source

  def initialize(attributes = {})
    self.source = attributes[:source]
  end

  def process(amount: 99, currency: "usd")
    Stripe::Charge.create(amount: amount, currency: currency, source: source)
  end
end

app/controllers/checkouts_controller.rb

class CheckoutsController < ApplicationController

  def new
    @checkout = Checkout.new

    respond_to do |format|
      format.html
    end
  end

  def create
    @checkout = Checkout.new(params.require(:checkout).permit(:source))
    @checkout.process

    respond_to do |format|
      format.html { redirect_to root_path }
    end
  end

end

config/routes.rb

Rails.application.routes.draw do
  resource :checkout, only: %i(new create)
end

app/views/checkouts/new.html.erb

<%= form_for @checkout, url: checkout_path, class: "form-group" do |form| %>
  <%= form.hidden_field :source %>

  <div class="form-group">
    <input class="form-control" type="text" placeholder="Name" />
  </div>

  <div class="form-group">
    <input class="form-control" type="text" placeholder="Number" />
  </div>

  <div class="form-group">
    <input class="form-control" type="text" placeholder="Expiration" />
  </div>

  <div class="form-group">
    <input class="form-control" type="text" placeholder="CVC" />
  </div>

  <%= form.submit "Checkout", class: 'btn' %>
<% end %>

At this point the sample app has a /checkout/new endpoint with a basic form. Submitting the form (with or without data) raises an error that the source is blank (which is expected). The form does not send the number or CVC to the server.

Wiring Up A Component

Components are a core features of Vue.js. Typically components contain a template - (but they do not have to). For this sample app a component with an inline-template is used.

For the sample app a checkout component will handle the the tokenization of the card and subsequent submitting of the form. The component will have a data function (that initializes the values when the component loads) and a submit method (that is bound to our form submit). The markup for the checkout form is modified to contain binding to the submit method v-on and binding of the checkout attributes v-model. A special errors key is added for validations that gets displayed using v-if.

app/assets/javascripts/components/checkout.coffee

Vue.component 'checkout', Vue.extend

  data: ->
    errors: {}
    number: null
    exp: null
    cvc: null

    methods:
      submit: ->
        @errors =
          number: ("cannot be blank" if !@number? or @number is "")
          exp: ("cannot be blank" if !@exp? or @exp is "")
          cvc: ("cannot be blank" if !@cvc? or @cvc is "")
        unless @errors.number or @errors.exp or @errors.cvc
          card =
            number: @number
            exp: @exp
            cvc: @cvc
          Stripe.card.createToken card, (status, response) =>
            if response.error?
              @errors = base: response.error.message
            else
              @$els.token.value = response.id
              @$els.form.submit()

app/views/checkouts/new.html.erb

<checkout inline-template>
  <%= form_for @checkout, url: checkout_path, class: "form-group",
    html: { "v-on:submit.stop.prevent": "submit" } do |form| %>

    <div class="alert alert-danger" role="alert" v-if="errors.base">{{ errors.base }}</div>

    <div class="form-group" :class="{ 'has-danger': errors.number }">
      <input class="form-control" type="text" placeholder="Number" v-model="number"
        :class="{ 'form-control-danger': errors.number }" />
      <div class="form-control-feedback" v-if="errors.number" v-cloak>{{ errors.number }}</div>
    </div>

    <div class="form-group" :class="{ 'has-danger': errors.cvc }">
      <input class="form-control" type="text" placeholder="CVC" v-model="cvc"
        :class="{ 'form-control-danger': errors.cvc }" />
      <div class="form-control-feedback" v-if="errors.cvc" v-cloak>{{ errors.cvc }}</div>
    </div>

    <div class="form-group" :class="{ 'has-danger': errors.exp }">
      <input class="form-control" type="text" placeholder="Expiration" v-model="exp"
        :class="{ 'form-control-danger': errors.exp }" />
      <div class="form-control-feedback" v-if="errors.exp" v-cloak>{{ errors.exp }}</div>
    </div>

    <%= form.submit "Checkout", class: 'btn' %>
  <% end %>
</checkout>

At this point the sample app's /checkout/new endpoint works. If any of the fields are blank inline errors appear. If the card is invalid a global error appears. If everything is valid the form is submitted and a charge is placed.

Wiring Up A Directive

Directives are another core features of Vue.js. Vue.js ships with a number of standard directives (all the custom properties like v-model, v-if and v-on are directives).

The sample will use a payment directive that integrates with the jQuery.payment to format the card inputs. This is done by registering a directive with methods for bind and unbind (that add and remove the jQuery.payment snippets).

app/assets/javascripts/directives/payment.coffee

Vue.directive "payment",

  bind: (value) ->
    method = switch @expression
      when "format-card-number" then "formatCardNumber"
      when "format-card-exp" then "formatCardExpiry"
      when "format-card-cvc" then "formatCardCVC"
    $(@el).payment(method)

  unbind: ->
    $(@el).unbind()

app/views/checkouts/new.html.erb

<input class="form-control" type="text" placeholder="Number"
  v-payment="format-card-number" v-model="number"
  :class="{ 'form-control-danger': errors.number }" />

<input class="form-control" type="text" placeholder="CVC"
  v-payment="format-card-cvc" v-model="cvc"
  :class="{ 'form-control-danger': errors.cvc }" />

<input class="form-control" type="text" placeholder="Expiration"
  v-payment="format-card-expiration" v-model="exp"
  :class="{ 'form-control-danger': errors.exp }" />

The Wrap Up

A fully functioning checkout requires carts / orders / etc - none of which are explored in this tutorial. It is also one of the most important components of an application to properly test (of which none is done here). Finally more advanced validations are easily availability for the form (but excluded for brevity). However - the pattern of using Vue.js to augment forms or views (via components) and individual DOM elements (via directives) reactively is a promising approach to augmenting Ruby on Rails apps.