Have you ever wondered how many ways there are to build a Ruby on Rails application with VueJS?
This is the first of three articles which explain step by step how you can build a Rails application with VueJS with some advice on which technique you should use based on your needs.
JSX is an extension of JavaScript. It can be used with VueJS to build components avoiding to use .vue
templates.
With this approach, we can build a large and scalable frontend easily.
JSX syntax is recommended to integrate VueJS to an existing complex project or to start a project which needs a bold framework like Solidus.
Here are some of the advantages of using JSX:
And some of the disadvantages:
.vue
templatesv-for
, v-if
, etc.You can find the code in this GitHub repository.
Branches:
master
: Rails products catalog application without Webpack and VueJSvuejs-jsx
: integration of Webpack and VueJS on the Ruby on Rails applicationWe’ll start from an existing Rails application and will move it step by step to VueJS.
Clone the repository and bootstrap the project:
$ git clone https://github.com/vassalloandrea/rails-vuejs-jsx.git
$ cd rails-vuejs-jsx
$ asdf local ruby 2.5.1 # If you use asdf as version manager
$ ./bin/setup
$ bundle exec rails s
The application is a products catalog.
The root path shows the list of the products and clicking on one of them reveals the product details. For each product you can read, add or delete the related comments.
Our goal is to move some parts of this app into VueJS components.
To run VueJS code in the Rails application, we need to install Webpack which is a static module bundler.
A Rails application is usually built with Sprockets to compile and serve web assets. Both libraries can live together.
gem 'webpacker', '~> 4.x'$ bundle install
$ bundle exec rails webpacker:install
The installation command generates all the files needed to configure Webpack on Rails.
To manage all the JS dependencies we use yarn
. Install Node using your favorite version manager, I usually use asdf
$ asdf install nodejs 10.16.0
$ asdf local nodejs 10.16.0
and install yarn
$ npm i -g yarn@1.16.0
To check if the project is now working with Webpack, restart the Rails server and the webpack-dev-server
$ yarn install
$ bundle exec rails server
$ ./bin/webpack-dev-server
2. Now add the pack link in your application.html.erb
file
<head>
<title>RailsVuejsJsx</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
3. If everything is set in the correct way, you should see the Webpacker message in the browser console
1. Install VueJS using the Webpacker command:
$ bundle exec rails webpacker:install:vue
2. Remove useless files like: hello_vue.js
and app.vue
3. If your project uses Turbolinks, install the vue-turbolinks
library
$ yarn add vue-turbolinks
4. Edit the application.js
file, pay attention here: we MUST change theapplication.js
in the javascript/packs
directory of the app
/* eslint no-console:0 */
import TurbolinksAdapter from 'vue-turbolinks'
import Vue from 'vue'
// Import all the macro components of the application
import * as instances from '../instances'
Vue.use(TurbolinksAdapter)
document.addEventListener('turbolinks:load', () => {
// Initialize available instances
Object.keys(instances).forEach((instanceName) => {
const instance = instances[instanceName]
const elements = document.querySelectorAll(instance.el)
elements.forEach((element) => {
const props = JSON.parse(element.getAttribute('data-props'))
new Vue({
el: element,
render: h => h(instance.component, { props })
})
})
})
})
5. Create the instances.js
file which contains all the Vue instances, the application macro-areas that you want to migrate to Vue
app/javascript/instances.js
// Import components
import ProductList from './components/product/index'
export const ProductListInstance = {
el: '.vue-products',
component: ProductList
}
6. Add your first Vue component app/javascript/components/product/index.js
that shows the product list
export default {
name: 'ProductList',
render() {
return(
<h1>Products catalog</h1>
)
}
}
7. Replace the content of app/views/products/index.html.erb
:
<div class="vue-products"></div>
If you restart the Rails and Webpack server, you should see an error:
The problem is that Babel doesn’t have the correct preset to understand the JSX syntax with VueJS. To solve the issue, we must add the preset and configure Babel to use it.
$ yarn add @vue/babel-preset-jsx @vue/babel-helper-vue-jsx-merge-props
Open the Babel configuration file babel.config.js
and add the preset to the presets array. At the end it should look like this:
presets: [
isTestEnv && [
require('@babel/preset-env').default,
{
targets: {
node: 'current'
}
}
],
(isProductionEnv || isDevelopmentEnv) && [
require('@babel/preset-env').default,
{
forceAllTransforms: true,
useBuiltIns: 'entry',
corejs: 3,
modules: false,
exclude: ['transform-typeof-symbol']
}
],
'@vue/babel-preset-jsx'
].filter(Boolean)
If you restart the Webpack server and reload the page, you should see your first component.
The fastest way to do this should be to copy the html.erb
template in the component file and replace the ERB code with JSX.
In this example, I copied the content of app/views/products/index.html.erb
to app/javascript/components/product/index.js
and deleted the Rails code.
When you build a VueJS application, it’s very important to create a component for every piece of code that has a different context. For example: product/index.js
will show the list of products but each product should be a separated component called product/card.js
.
Here are the results:
app/javascript/components/product/index.js
import ProductCard from './card'
export default {
name: 'ProductList',
props: {
products: Array
},
render() {
return(
<div>
<h1 class="my-4">
Products catalog
</h1>
<div class="row">
{this.products.map(product => (
<ProductCard product={product} />
))}
</div>
</div>
)
}
}
app/javascript/components/product/card.js
export default {
name: 'ProductCard',
props: {
product: Object
},
methods: {
shortDescription() {
let description = this.product.description
if (description.length > 50) {
return `${description.substr(0, 50)}...`
} else {
return description
}
}
},
render() {
return(
<div class="col-lg-4 col-sm-6 mb-4">
<div class="card h-100">
<a href={this.product.url}>
<img src={this.product.image} class="card-img-top" alt="" />
</a>
<div class="card-body">
<h4 class="card-title">
<a href={this.product.url}>
{ this.product.name }
</a>
</h4>
<p class="card-text">
{ this.shortDescription() }
</p>
</div>
</div>
</div>
)
}
}
props
. props
is a JS object which contains the params passed by the parent component. In this example, you have to pass the products to the ProductCard component when you render it.app/views/products/index.html.erb
<% props = { products: serialize('serializers/products', products: @products) }.to_json %>
<div class="vue-products" data-props="<%= props %>"></div>
serialize
method is a helper method which you must add to app/helpers/application_helper.rb
module ApplicationHelper
def serialize(template, options = {})
JbuilderTemplate
.new(self) { |json| json.partial! template, options }.attributes!
end
end
app/views/serializers/_products.jbuilder
json.array! products do |product|
json.partial! 'serializers/product', product: product
end
app/views/serializers/_product.jbuilder
json.id product.id
json.name product.name
json.description product.description
json.image url_for(product.image)
json.url product_path(product)
Now the product list page should work showing the list of the products using VueJS.
Vuex is a VueJS library that enables us to share the state of the application between all the Vue instances and components that use it.
If you want to pass some data from a parent to a child you could use props
and the state is not needed, but what happens if a sibling component changes some data that is showed from another component?
Vuex resolves this problem centralizing the application data.
A Vuex instance, usually called store, could have many modules. Each module is a JS object which has a state, some actions, mutations and getters.
Install vuex
$ yarn add vuex
Configure the store: a little bit of boilerplate
app/javascript/store/index.js
file which creates the Vuex instance import Vue from 'vue'
import Vuex from 'vuex'
import modules from './modules'
Vue.use(Vuex)
export default new Vuex.Store({
modules,
strict: process.env.NODE_ENV !== 'production'
})
app/javascript/store/modules/index.js
file which includes all the store modules import product from './product'
export default {
product
}
app/javascript/store/modules/product.js
const defaultState = {
comments: []
}
export const actions = {
fillComments({ commit }, comments) {
commit('fillComments', comments)
}
}
export const mutations = {
fillComments(state, comments) {
state.comments = comments
}
}
export default {
state: defaultState,
actions,
mutations
}
app/javascript/packs/application.js
file like this ...
...
// Import the store
import store from '../store'
...
...
...
new Vue({
el: element,
store,
render: h => h(instance.component, { props })
})
Install and configure i18n-js gem
This is used to share translations between Rails and Javascript.
i18n-js
gem to the Gemfilebundle install
//= require i18n/translations
into the app/assets/javascripts/application.js
fileAs initially said, we can move the whole application to VueJS or only some of its sections. In this case, we are moving the product list, the comments list and the comment form.
app/javascript/instances.js
// Import components
import ProductList from './components/product/index'
import CommentList from './components/comment/index'
export const ProductListInstance = {
el: '.vue-products',
component: ProductList
}
export const CommentListInstance = {
el: '.vue-comments',
component: CommentList
}
app/javascript/components/comment/index.js
import { mapState, mapActions } from 'vuex'
import CommentCard from './card'
export default {
name: 'CommentList',
props: {
product: Object
},
computed: {
...mapState({
comments: state => state.product.comments
})
},
methods: {
...mapActions({
fillComments: 'fillComments'
}),
thereAreComments() {
return this.comments.length > 0
}
},
mounted() {
this.fillComments(this.product.comments)
},
render() {
return(
<div>
<h4 class="my-4">Comments</h4>
<div class="row">
{this.thereAreComments() &&
this.comments.map(comment => (
<CommentCard comment={comment} />
))
}
{!this.thereAreComments() &&
<div class="col-md-12">
<p>
{ I18n.t('comments.empty') }
</p>
</div>
}
</div>
</div>
)
}
}
app/javascript/components/comment/card.js
import { mapActions } from 'vuex'
export default {
name: 'CommentCard',
props: {
comment: Object
},
methods: {
...mapActions({
cancelComment: 'cancelComment'
})
},
render() {
return(
<div class="col-md-12 my-2">
<div class="card">
<div class="card-body">
<h5 class="card-title">{ this.comment.title }</h5>
<p class="card-text">{ this.comment.description }</p>
<button class="btn btn-sm btn-danger" onClick={event => this.cancelComment(this.comment.id)}>
{ I18n.t('comments.form.delete') }
</button>
</div>
</div>
</div>
)
}
}
app/views/shared/_comments.html.erb
app/views/products/show.html.erb
with this: <h1 class="my-4">
<%= @product.name %>
</h1>
<p>
<%= link_to t('products.back'), products_path %>
</p>
<div class="row">
<div class="col-md-8">
<%= image_tag @product.image, class: 'img-fluid' %>
</div>
<div class="col-md-4">
<h3 class="my-3">Project Description</h3>
<p>
<%= @product.description %>
</p>
</div>
</div>
<%
props = {
product: serialize('serializers/product', product: @product)
}.to_json
%>
<div class="vue-comments" data-props="<%= props %>"></div>
app/views/serializers/_product.jbuilder
: json.comments product.comments do |comment|
json.partial! 'serializers/comment', comment: comment
end
app/views/serializers/_comment.jbuilder
: json.id comment.id
json.title comment.title
json.description comment.description
$ bundle exec rails console product = Product.first
Comment.create!(title: 'This is the first comment', description: 'Comment description', product: product)
Comment.create!(title: 'This is the second comment', description: 'Comment description', product: product)
At this point, you should see the page like before with the comment list. However, the delete comment button doesn’t work. This happens because the action deleteComment
wasn’t implemented into the store.
To add or delete a comment without reloading the product page, we must implement the APIs and consume them using the Axios library.
$ yarn add axios
config/routes.rb
file Rails.application.routes.draw do
root 'products#index'
resources :products, only: %i[index show]
namespace :api do
resources :comments, only: :destroy
resources :products, only: [] do
resources :comments, only: :create
end
end
end
2. Remove app/controllers/comments_controller.rb
3. Create the API comments controller app/controllers/api/comments_controller.rb
4. Implement the create
and destroy
methods
Rails.application.routes.draw do
root 'products#index'
resources :products, only: %i[index show]
namespace :api do
resources :comments, only: :destroy
resources :products, only: [] do
resources :comments, only: :create
end
end
end
To recap, we added a button to delete a comment to the comment card component.
When the user clicks on the delete button, the component should dispatch the correct action, e.g. deleteComment
.
The action calls the correct API method (which doesn’t exist yet) and it will commit the correct mutation based on the response.
If the destroy API call was successful, remove the deleted comment from the comments array.
If the destroy API call was unsuccessful, fill the errors array to show the errors.
Implement the API client with Axios
app/javascript/api/instance.js
import axios from 'axios'
axios.defaults.headers.common['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
export default axios.create()
2. Add the index file app/javascript/api/index.js
which exports all the API modules. In this case, we use Axios only to manage the comments.
import comment from './comment'
export default {
comment
}
3. Implement the methods that will make the HTTP request: app/javascript/api/comment.js
import api from './instance'
/**
* Create a comment
*/
const create = (productId, commentParams) => (
api.post(Routes.api_product_comments_path(productId), commentParams)
.then(response => response.data)
)
/**
* Destroy a comment
*/
const destroy = (commentId) => (
api.delete(Routes.api_comment_path(commentId))
.then(response => response.data)
)
export default {
create,
destroy
}
Install and configure js-routes gem
This gem is needed to share Rails routes with JavaScript.
bundle install
:gem ‘js-routes’
2. Require the gem in app/assets/javascripts/application.js
:
//= require js-routes
3. Configure js-routes specifying which routes should be shared by creating the configuration file: config/initializers/js_routes.rb
# frozen_string_literal: true
JsRoutes.setup do |config|
config.include = [
/^api_comment$/,
/^api_product_comments$/,
]
end
4. Run this commands and restart the Rails server:
$ bundle exec rails tmp:cache:clear
Implement the action and the mutations
app/javascript/store/modules/product.js
import api from '../../api'
2. Add the method to the actions object:
cancelComment({ commit }, commentId) {
api.comment.destroy(commentId)
.then(() => {
commit('commentCancelled', commentId)
})
}
3. Add the method commentCancelled
to the mutation object:
commentCancelled(state, commentId) {
state.comments = state.comments.filter(comment => commentId !== comment.id)
},
The commentCancelled
method will filter the comments array removing the canceled comment.
At this point, the delete comment feature should work.
Since we removed the comment form partial from the product show view, the form disappeared from the page. To fix this, we will create the commentForm
Vue component.
app/javascript/components/comment/form.js
import { mapActions } from 'vuex'
export default {
props: {
product: Object
},
data() {
return {
title: '',
description: ''
}
},
methods: {
...mapActions({
addComment: 'addComment'
}),
submitComment() {
this.addComment({
productId: this.product.id,
commentParams: {
title: this.title,
description: this.description
}
})
this.title = ''
this.description = ''
}
},
render() {
return(
<div class="row my-2">
<div class="col-md-8">
<h4 class="my-4">Add new comment</h4>
<div class="form-label-group">
<input type="input" class="form-control" name="title"
placeholder={I18n.t('comments.form.title')}
autofocus="true" vModel_trim={this.title} />
</div>
<div class="form-label-group my-3">
<input type="input" class="form-control" name="description"
placeholder={I18n.t('comments.form.description')}
vModel_trim={this.description} />
</div>
<input type="submit" class="btn btn-primary" value={I18n.t('comments.form.submit')}
vOn:click_stop_prevent={this.submitComment} />
</div>
</div>
)
}
}
2. Add the component to the instances file: app/javascript/instances.js
// Import components
import ProductList from './components/product/index'
import CommentList from './components/comment/index'
import CommentForm from './components/comment/form'
export const ProductListInstance = {
el: '.vue-products',
component: ProductList
}
export const CommentListInstance = {
el: '.vue-comments',
component: CommentList
}
export const CommentFormInstance = {
el: '.vue-comment-form',
component: CommentForm
}
3. Add the commentForm wrapper at the end of the product show: app/views/products/show.html.erb
<div class="vue-comment-form" data-props="<%= props %>">
</div>
Now, the comment form appears at the end of the product detail page again, but it doesn’t work.
addComment
action that calls the create method of the APIs in app/javascript/store/modules/product.js
addComment({ commit }, { productId, commentParams }) {
api.comment.create(productId, commentParams)
.then((comment) => {
commit('commentAdded', comment)
})
}
2. Add the commentAdded
mutation which updates the comments array in app/javascript/store/modules/product.js
commentAdded(state, comment) {
state.comments.push(comment)
}
Now your application uses both Rails and VueJS to render views and components. To learn how to manage errors with the comment form, you can use the repository linked above.
#vue-js #ruby-on-rails #web-development