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.

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.

  1. Automatically building AngularJS routing based on routes.rb
  2. Auto Generate AngularJS Resources from routes.rb
  3. Setting architectural rigor for AngularJS controllers
  4. Prescribing constantly used configs
  5. Form validation
  6. FormBuilder automatically affixing ng-model
  7. Notification
  8. Commonly used directives (ajax fileupload, click-outside, content-for, check-list)
  9. 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.

Also popular now: