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.
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
.
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.
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.
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 }" />
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.