Architecture for building Single Page Application based on AngularJS and Ruby on Rails
Having become interested in the methodology for building SPA applications on Ruby on Rails, I came up with some ideas that are now implemented in each of my applications and subsequently were even separated into a separate Oxymoron gem . At the moment, Oxymoron has written more than 20 fairly large commercial rail applications. I want to take the heme to a public court. Therefore, I will conduct my further narrative based on it.
An example of a finished application.
For me, this gem reduces the amount of routine code by an order of magnitude and, as a result, significantly increases the development speed. It makes it very easy to build AngularJS + RoR interoperability.
First of all, you need to connect the gem to the Gemfile:
Now, every time you change routes.rb , or when you restart the application, an oxymoron.js file will be generated in app / assets / javascripts , which contains all the necessary code to build the application. The next step is to configure the assets. In the simplest case, it looks like this: For application.js:
For application.css:
We use the UI Router, so you need to define the ui-view tag in our layout. Since the application will use HTML5 routing, you must specify the base tag. In our case, this is application.html.slim. I use SLIM as a preprocessor and strongly advise everyone.
For all AJAX requests, layout must be turned off. To do this, write the necessary logic in the ApplicationController:
For the correct processing of forms and the installation of ng-model, you must create an initializer that overrides the default FormBuilder to OxymoronFormBuilder.
The last thing you need to do is inject the oxymoron module into your application and tell the UI Router that the automatically generated routing will be used:
Everything is ready to create a full-fledged SPA application!
So. First, prepare the Post model and the RESTful controller to manage this model. To do this, execute the following commands in the console:
In routes.rb, create the posts resource:
Now we describe the methods of our controller. Often, the same method can return both JSON structures and HTML markup in the response, such methods must be wrapped in respond_to .
Each Rails controller has an AngularJS controller. The matching rule is very simple:
Create the appropriate controller in app / javascripts / controllers / post_ctrl.js :
Pay attention to the factory action . Using it is very convenient to split the code between the pages of the application. The factory resolves through the generated state in oxymoron.js and, as a result, knows the current rail method of the controller.
Next, pay attention to the Post factory . This factory is automatically generated from the resource defined in routes.rb. For proper generation, the show method must be defined on the resource. The following methods of working with the resource are available from the box:
Custom resource methods (member and collection) work the same way. For instance:
Create the appropriate method for the AngularJS resource:
Set the is_array: true option if it is expected that an array is expected in response. Otherwise, AngularJS will throw an exception.
It remains to create the missing views.
Particular attention should be paid to the result of the generation of the form_for helper.
It is enough to define the ctrl.save method inside the controller and it will be executed every time you submit the form and pass the parameters that you see. But since these parameters are ideally suited as arguments for the update and create resource methods, we can only write ctrl.save = Post.create in our controller . In the PostsCtrl listing, this point is marked with a corresponding comment.
The ng-model attribute was automatically added for the text_field and text_area tags . The ng-model compilation rule is as follows:
In the rail listing of PostsController, you probably noticed the msg, redirect_to, and so on fields in the render method. A special interceptor works for these fields, which performs the necessary action before the result is transmitted to the controller.
msg - the contents will be shown in a green pop-up at the top of the screen. If you pass the status of an error to render, then the color changes to red
errors - accepts the errors object, serves to display errors directly on the form fields themselves.
redirect_to - redirect to the required UI Router state
redirect_to_options - if the state requires an option, for example, the show page requires id, you must specify them in this field
redirect_to_url- follow the specified url
reload - completely reload the page to the user
All these actions occur without reloading the user page. HTML5 routing based on UI Router is used.
Previously, we had to use the link_to helper when we wanted to define a link depending on the name of the route. Now this feature is implemented by ui-sref in the usual manner of describing the route.
In the global scope, you can find the Routes variable. It works almost exactly the same as js-routes. The only difference is that this implementation accepts only the object and does not have sugar as a number argument. There may be a conflict, so I recommend disabling js-routes.
We wrote a primitive SPA application. In this case, the code looks absolutely rail, the logic is described at a minimum, and the one that is already is the most common. I understand that there is no limit to perfection and that Oxymoron is far from ideal, however, I hope that I was able to interest someone in my approach. I will be glad to any criticism and any positive participation in the life of the gem.
An example of a finished application.
An example of a finished application.
What tasks does Oxymoron solve?
For me, this gem reduces the amount of routine code by an order of magnitude and, as a result, significantly increases the development speed. It makes it very easy to build AngularJS + RoR interoperability.
- Automatically building AngularJS routing based on routes.rb
- Auto Generate AngularJS Resources from routes.rb
- Setting architectural rigor for AngularJS controllers
- Prescribing constantly used configs
- Form validation
- FormBuilder automatically affixing ng-model
- Notification
- Commonly used directives (ajax fileupload, click-outside, content-for, check-list)
- Implementation of a compact analogue of JsRoutes
How it works?
First of all, you need to connect the gem to the Gemfile:
gem 'oxymoron'
Now, every time you change routes.rb , or when you restart the application, an oxymoron.js file will be generated in app / assets / javascripts , which contains all the necessary code to build the application. The next step is to configure the assets. In the simplest case, it looks like this: For application.js:
/*
= require oxymoron/underscore
= require oxymoron/angular
= require oxymoron/angular-resource
= require oxymoron/angular-cookies
= require oxymoron/angular-ui-router
= require oxymoron/ng-notify
= require oxymoron
= require_self
= require_tree ./controllers
*/
For application.css:
/*
*= require oxymoron/ng-notify
*= require_self
*/
We use the UI Router, so you need to define the ui-view tag in our layout. Since the application will use HTML5 routing, you must specify the base tag. In our case, this is application.html.slim. I use SLIM as a preprocessor and strongly advise everyone.
html ng-app="app"
head
title Блог
base href="/"
= stylesheet_link_tag 'application'
body
ui-view
= javascript_include_tag 'application'
For all AJAX requests, layout must be turned off. To do this, write the necessary logic in the ApplicationController:
layout proc {
if request.xhr?
false
else
"application"
end
}
For the correct processing of forms and the installation of ng-model, you must create an initializer that overrides the default FormBuilder to OxymoronFormBuilder.
ActionView::Base.default_form_builder = OxymoronFormBuilder
The last thing you need to do is inject the oxymoron module into your application and tell the UI Router that the automatically generated routing will be used:
var app = angular.module("app", ['ui.router', 'oxymoron']);
app.config(['$stateProvider', function ($stateProvider) {
$stateProvider.rails()
}])
Everything is ready to create a full-fledged SPA application!
Let's write the simplest SPA blog
So. First, prepare the Post model and the RESTful controller to manage this model. To do this, execute the following commands in the console:
rails g model post title:string description:text
rake db:migrate
rails g controller posts index show
In routes.rb, create the posts resource:
Rails.application.routes.draw do
root to: "posts#index"
resources :posts
end
Now we describe the methods of our controller. Often, the same method can return both JSON structures and HTML markup in the response, such methods must be wrapped in respond_to .
Typical Rails Controller Example
class PostsController < ActiveRecord::Base
before_action :set_post, only: [:show, :edit, :update, :destroy]
def index
respond_to do |format|
format.html
format.json {
@posts = Post.all
render json: @posts
}
end
end
def show
respond_to do |format|
format.html
format.json {
render json: @post
}
end
end
def new
respond_to do |format|
format.html
format.json {
render json: Post.new
}
end
end
def edit
respond_to do |format|
format.html
format.json {
render json: @post
}
end
end
def create
@post = Post.new post_params
if @post.save
render json: {post: @post, msg: "Post successfully created", redirect_to: "posts_path"}
else
render json: {errors: @post.errors, msg: @post.errors.full_messages.join(', ')}, status: 422
end
end
def update
if @post.update(post_params)
render json: {post: @post, msg: "Post successfully updated", redirect_to: "posts_path"}
else
render json: {errors: @post.errors, msg: @post.errors.full_messages.join(', ')}, status: 422
end
end
def destroy
@post.destroy
render json: {msg: "Post successfully deleted"}
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :description)
end
end
Each Rails controller has an AngularJS controller. The matching rule is very simple:
PostsController => PostsCtrl
Admin::PostsController => AdminPostsCtrl # для контроллеров внутри namespace Admin
Create the appropriate controller in app / javascripts / controllers / post_ctrl.js :
An example of a typical AngularJS controller
app.controller('PostsCtrl', ['Post', 'action', function (Post, action) {
var ctrl = this;
// Код отработает только для '/posts'
action('index', function(){
ctrl.posts = Post.query();
});
// Вызовется для паттерна '/posts/:id'
action('show', function (params){
ctrl.post = Post.get({id: params.id});
});
// Только для '/posts/new'
action('new', function(){
ctrl.post = Post.new();
// Присваивание каллбека создания, который будет вызван автоматически при сабмите формы. См. ниже.
ctrl.save = Post.create;
});
// Для паттерна '/posts/:id/edit'
action('edit', function (params){
ctrl.post = Post.edit({id: params.id});
// Аналогичное присваивание для каллбека обновления
ctrl.save = Post.update;
})
// Общий код. Вызовется для двух методов edit и new.
action(['edit', 'new'], function(){
//
})
action(['index', 'edit', 'show'], function () {
ctrl.destroy = function (post) {
Post.destroy({id: post.id}, function () {
ctrl.posts = _.select(ctrl.posts, function (_post) {
return _post.id != post.id
})
})
}
})
// Так же внутри ресурса routes.rb можно создать свой кастомный метод. Вызовется для: '/posts/some_method'
action('some_method', function(){
//
})
// etc
}])
Pay attention to the factory action . Using it is very convenient to split the code between the pages of the application. The factory resolves through the generated state in oxymoron.js and, as a result, knows the current rail method of the controller.
action(['edit', 'new'], function(){
// код выполнится только на страницах posts/new и posts/:id/edit
})
Next, pay attention to the Post factory . This factory is automatically generated from the resource defined in routes.rb. For proper generation, the show method must be defined on the resource. The following methods of working with the resource are available from the box:
Post.query() // => GET /posts.json
Post.get({id: id}) // => GET /posts/:id.json
Post.new() // => GET /posts/new.json
Post.edit({id: id}) // => GET /posts/:id/edit.json
Post.create({post: post}) // => POST /posts.json
Post.update({id: id, post: post}) // => PUT /posts/:id.json
Post.destroy({id: id}) // => DELETE /posts/:id.json
Custom resource methods (member and collection) work the same way. For instance:
resources :posts do
member do
get "comments", is_array: true
end
end
Create the appropriate method for the AngularJS resource:
Post.comments({id: id}) //=> posts#comments
Set the is_array: true option if it is expected that an array is expected in response. Otherwise, AngularJS will throw an exception.
It remains to create the missing views.
posts / index.html.slim
h1 Posts
input.form-control type="text" ng-model="search" placeholder="Поиск"
br
table.table.table-bordered
thead
tr
th Date
th Title
th
tbody
tr ng-repeat="post in ctrl.posts | filter:search"
td ng-bind="post.created_at | date:'dd.MM.yyyy'"
td
a ui-sref="post_path(post)" ng-bind="post.title"
td.w1
a.btn.btn-danger ng-click="ctrl.destroy(post)" Удалить
a.btn.btn-primary ui-sref="edit_post_path(post)" Редактировать
posts / show.html.slim
.small ng-bind="ctrl.post.created_at | date:'dd.MM.yyyy'"
a.btn.btn-primary ui-sref="edit_post_path(ctrl.post)" Редактировать
a.btn.btn-danger ng-click="ctrl.destroy(ctrl.post)" Удалить
h1 ng-bind="ctrl.post.title"
p ng-bind="ctrl.post.description"
posts / new.html.slim
h1 New post
= render 'form'
posts / edit.html.slim
h1 Edit post
= render 'form'
posts / _form.html.slim
= form_for Post.new do |f|
div
= f.label :title
= f.text_field :title
div
= f.label :description
= f.text_area :description
= f.submit "Save"
Particular attention should be paid to the result of the generation of the form_for helper.
It is enough to define the ctrl.save method inside the controller and it will be executed every time you submit the form and pass the parameters that you see. But since these parameters are ideally suited as arguments for the update and create resource methods, we can only write ctrl.save = Post.create in our controller . In the PostsCtrl listing, this point is marked with a corresponding comment.
The ng-model attribute was automatically added for the text_field and text_area tags . The ng-model compilation rule is as follows:
ng-model="ctrl.название_модели.название_поля"
Render json functionality: {}
In the rail listing of PostsController, you probably noticed the msg, redirect_to, and so on fields in the render method. A special interceptor works for these fields, which performs the necessary action before the result is transmitted to the controller.
msg - the contents will be shown in a green pop-up at the top of the screen. If you pass the status of an error to render, then the color changes to red
errors - accepts the errors object, serves to display errors directly on the form fields themselves.
redirect_to - redirect to the required UI Router state
redirect_to_options - if the state requires an option, for example, the show page requires id, you must specify them in this field
redirect_to_url- follow the specified url
reload - completely reload the page to the user
All these actions occur without reloading the user page. HTML5 routing based on UI Router is used.
Now without link_to
Previously, we had to use the link_to helper when we wanted to define a link depending on the name of the route. Now this feature is implemented by ui-sref in the usual manner of describing the route.
a ui-sref="posts_path" Все посты
a ui-sref="post_path({id: 2})" Пост №2
a ui-sref="edit_post_path({id: 2})" Редактирование поста №2
a ui-sref="new_post_path" Создание нового поста
Lightweight analogue of js-routes. CONFLICT
In the global scope, you can find the Routes variable. It works almost exactly the same as js-routes. The only difference is that this implementation accepts only the object and does not have sugar as a number argument. There may be a conflict, so I recommend disabling js-routes.
Routes.posts_path() // => "/posts"
Routes.new_post_path() // => "/post/new"
Routes.edit_posts_path({id: 1}) // => "/post/1/edit"
// Параметры по умолчанию
Routes.defaultParams = {id: 1}
Routes.post_path({format: 'json'}) // => "/posts/1.json"
Total
We wrote a primitive SPA application. In this case, the code looks absolutely rail, the logic is described at a minimum, and the one that is already is the most common. I understand that there is no limit to perfection and that Oxymoron is far from ideal, however, I hope that I was able to interest someone in my approach. I will be glad to any criticism and any positive participation in the life of the gem.
An example of a finished application.