Mario Alberto Cháve

Blog personal desarrollo de software

desarrollo

I created the same app with Rails and Javascript

I created the same app with Rails and Javascript

A few days ago, I came into a post on Medium “I created the exact same app in React and Svelte. Here are the differences”, this post talks about side by side code comparison on writing a simple To Do application with both Javascript Framework.

Sunil Sandhu, the author of the post, is familiar with React by using it at work, and he wrote the post based on his exploration of Svelte and his experience with React.

It is an excellently written post with much useful information, so he tackled the work of creating a second post comparing Svelte and Vue “I created the exact same app in Vue and Svelte. Here are the differences”.

While both posts have great information, I have always been vocal about why not try the old good Web foundation before jumping directly into a Javascript framework that takes over the application heart.

So I decided to use his same approach and wrote this post about using HTML, SASS, and StimulusJs with a Rails backend.

The Rails project

For this project backend needs, there is no need for all the Rails frameworks. Fortunately, a project can be customized since creating to include what is need it. The rails command gives many options to what includes and what left out.

$ rails --help
$ Usage:
  rails new APP_PATH [options]

Options:
      [--skip-namespace], [--no-skip-namespace]              # Skip namespace (affects only isolated applications)
  -r, [--ruby=PATH]                                          # Path to the Ruby binary of your choice
                                                             # Default: /Users/marioch/.rbenv/versions/2.6.3/bin/ruby
  -m, [--template=TEMPLATE]                                  # Path to some application template (can be a filesystem path or URL)
  -d, [--database=DATABASE]                                  # Preconfigure for selected database (options: mysql/postgresql/sqlite3/oracle/frontbase/ibm_db/sqlserver/jdbcmysql/jdbcsqlite3/jdbcpostgresql/jdbc)
                                                             # Default: sqlite3
      [--skip-gemfile], [--no-skip-gemfile]                  # Don't create a Gemfile
  -G, [--skip-git], [--no-skip-git]                          # Skip .gitignore file
      [--skip-keeps], [--no-skip-keeps]                      # Skip source control .keep files
  -M, [--skip-action-mailer], [--no-skip-action-mailer]      # Skip Action Mailer files
      [--skip-action-mailbox], [--no-skip-action-mailbox]    # Skip Action Mailbox gem
      [--skip-action-text], [--no-skip-action-text]          # Skip Action Text gem
  -O, [--skip-active-record], [--no-skip-active-record]      # Skip Active Record files
      [--skip-active-storage], [--no-skip-active-storage]    # Skip Active Storage files
  -P, [--skip-puma], [--no-skip-puma]                        # Skip Puma related files
  -C, [--skip-action-cable], [--no-skip-action-cable]        # Skip Action Cable files
  -S, [--skip-sprockets], [--no-skip-sprockets]              # Skip Sprockets files
      [--skip-spring], [--no-skip-spring]                    # Don't install Spring application preloader
      [--skip-listen], [--no-skip-listen]                    # Don't generate configuration that depends on the listen gem
  -J, [--skip-javascript], [--no-skip-javascript]            # Skip JavaScript files
      [--skip-turbolinks], [--no-skip-turbolinks]            # Skip turbolinks gem
  -T, [--skip-test], [--no-skip-test]                        # Skip test files
      [--skip-system-test], [--no-skip-system-test]          # Skip system test files
      [--skip-bootsnap], [--no-skip-bootsnap]                # Skip bootsnap gem
      [--dev], [--no-dev]                                    # Setup the application with Gemfile pointing to your Rails checkout
      [--edge], [--no-edge]                                  # Setup the application with Gemfile pointing to Rails repository
      [--rc=RC]                                              # Path to file containing extra configuration options for rails command
      [--no-rc], [--no-no-rc]                                # Skip loading of extra configuration options from .railsrc file
      [--api], [--no-api]                                    # Preconfigure smaller stack for API only apps
  -B, [--skip-bundle], [--no-skip-bundle]                    # Don't run bundle install
  --webpacker, [--webpack=WEBPACK]                           # Preconfigure Webpack with a particular framework (options: react, vue, angular, elm, stimulus)
      [--skip-webpack-install], [--no-skip-webpack-install]  # Don't run Webpack install

...

By looking at the command usage information, a decision can be made based on project needs. By running the rails command with the following flags, the bootstrap process is cutting out many dependencies.

$ rails new frontend -M --skip-action-mailbox --skip-action-text --skip-active-storage --skip-action-cable --skip-sprockets --skip-javascript

Webpack will help in this project to handle assets like SASS, Javascript, and images. To install it, open the Gemfile and add the Webpacker gem. It is a wrapper for Webpack that helps with Rails integration.

# Gemfile

...
gem "webpacker", "~> 4.0"
...

Run the bundle command and then configure Webpack and install StimulusJs in the project.

$ bundle
$ bin/rails webpacker:install
$ bin/rails webpacker:install:stimulus

The project bootstrap is done and ready for you to focus on the functionality of this application.

Backend side

First, this application needs a Todo model with a Name attribute to stored To Dos data. The simple step to create the model is to take advantage of Rails generators for this.

$ bin/rails g model todo name
invoke  active_record
      create    db/migrate/20191219201444_create_todos.rb
      create    app/models/todo.rb
      invoke    test_unit
      create      test/models/todo_test.rb
      create      test/fixtures/todos.yml

A few files were created along with our model. For now, focus on db/migrate/20191219201444_create_todos.rb file; it is a database migration. Every time a database migration is created, you need to sure that it has database constraints required for a model; in this case, the name can not be null.

class CreateTodos < ActiveRecord::Migration[6.0]
  def change
    create_table :todos do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end

With changes in place, it is time to migrate the database.

$ bin/rails db:migrate

In the Ruby world, it is common to write automated tests, so why not write a few for the Todo model. Open the test file test/models/todo_test.rb and add the following tests.

require "test_helper"

class TodoTest < ActiveSupport::TestCase
  test "is valid" do
    subject = Todo.new todo_params

    assert subject.valid?
  end

  test "is invalid" do
    subject = Todo.new todo_params(name: "")

    refute subject.valid?
    refute_empty subject.errors[:name]
  end

  def todo_params(attributes = {})
    {name: "Test todo"}.merge(attributes)
  end
end

The tests are simple; they make sure the mode model is valid when all attributes meet requirements and invalid when not. To run the tests execute the following command.

$ bin/rails test
# Running:

F

Failure:
TodoTest#test_is_invalid [/Users/marioch/Development/personal/frontend/test/models/todo_test.rb:13]:
Expected true to not be truthy.


rails test test/models/todo_test.rb:10

.

Finished in 0.194414s, 10.2873 runs/s, 10.2873 assertions/s.
2 runs, 2 assertions, 1 failures, 0 errors, 0 skips

The runner reports failed tests; it is expected because the model under test is not validating any attributes requirements. The fix is straightforward, open the file app/models/todo.rb and add the following validations.

class Todo < ApplicationRecord
  validates :name, presence: true
end

Rerun the tests after the change, and now the runner reports that everything is ok.

$ bin/rails test
# Running:

..

Finished in 0.116393s, 17.1832 runs/s, 34.3663 assertions/s.
2 runs, 4 assertions, 0 failures, 0 errors, 0 skips

The last part of the Backend needs a controller, the TodosController. This time the controller will be created manually and not with the help of Rails generators, it must have three actions Index, Create, and Destroy.

Let us start with the routes of the application, open the file config/routes.rb, and add the following rules for TodosController actions.

Rails.application.routes.draw do
  resources :todos, only: [:index, :create, :destroy]

  root to: "todos#index"
end

Since automated tests are being written for this project, test data is required for us to write TodosController tests. A fixture is just that, test data available in tests only. To add a To Do fixture, open the file test/fixtures/todos.yml and add the following record, simple, right?

todo:
  name: "Fixture todo"

Now create the file test/controllers/todos_controller_test.rb, this file is used to write tests for TodosController. It is important to notice that tests for controllers only cares about the input and the response, nothing else.

require "test_helper"

class TodosControllerTest < ActionDispatch::IntegrationTest
  test "GET /todos" do
    get todos_path

    assert_response :success
  end

  test "POST /todos (success)" do
    post todos_path, params: {todo: {name: "Test todo"}}, as: :json

    assert_response :created

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert json_response.dig(:id).present?
    assert json_response.dig(:html).present?
  end

  test "POST /todos (failure)" do
    post todos_path, params: {todo: {name: ""}}, as: :json

    assert_response :unprocessable_entity

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert json_response.dig(:errors, :name).present?
  end

  test "DELETE /todos/:id" do
    todo = todos(:todo)
    delete todo_path(todo), as: :json

    assert_response :no_content
  end
end

A run on the tests report all controller tests with an error; it is because the TodosController does not exist.

$ bin/rails test
# Running:

E

Error:
TodosControllerTest#test_POST_/todos_(failure):
ActionController::RoutingError: uninitialized constant TodosController
Did you mean?  TodosControllerTest
    test/controllers/todos_controller_test.rb:20:in `block in <class:TodosControllerTest>'


rails test test/controllers/todos_controller_test.rb:19

...

E

Error:
TodosControllerTest#test_GET_/todos:
ActionController::RoutingError: uninitialized constant TodosController
Did you mean?  TodosControllerTest
    test/controllers/todos_controller_test.rb:5:in `block in <class:TodosControllerTest>'
.

It is time to add the TodosController. Create a file app/controllers/todos_controller.rb and add the code for all actions. Notice that Index action responds with HTML, Create with a JSON response, and Destroy with no content.

class TodosController < ApplicationController
  def index
    @todos = Todo.order(created_at: :desc)
    @todo = Todo.new
  end

  def create
    todo = Todo.new(todo_params)

    if todo.save
      todo_html = render_to_string(partial: "todos/todo", locals: {todo: todo}, formats: [:html])
      return render(json: {id: todo.id, html: todo_html}, status: :created)
    end

    render json: {errors: todo.errors.to_h}, status: :unprocessable_entity
  end

  def destroy
    todo = Todo.find_by(id: params[:id])
    todo.destroy

    render plain: "", status: :no_content
  end

  private

  def todo_params
    params.require(:todo).permit(:name)
  end
end

Let us try the tests again; much better, everything is green except for one test. The failing test indicates that Index action could not found an HTML template to render; it is ok for now; this template is added in the next section.

$ bin/rails test

# Running:

E

Error:
TodosControllerTest#test_GET_/todos:
ActionController::MissingExactTemplate: TodosController#index is missing a template for request formats: text/html
    test/controllers/todos_controller_test.rb:5:in `block in <class:TodosControllerTest>'


rails test test/controllers/todos_controller_test.rb:4

......

The Frontend side

The project is ready for us to work on the frontend. Since it uses Webpack, it is the right time to start the Webpack server and the Rails server; each one needs to run in its terminal session.

$ bin/webpack-dev-server
----
$ bin/rails s -p 3400

From the original React project, a few assets will be reused. To start, copy the contents of App.css, components/ToDo.css, and components/ToDoItem.css into a single file in our project, this file is app/javascript/stylesheets/application.scss.

rails-ujs library is a Javascript library from Rails that helps in what Rails community calls “Unobtrusive Javascript”, it makes Ajax call made by Rails helpers transparent. To install it, use Yarn.

$ bin/yarn add @rails-ujs

Also, a new logo for this project must be placed at app/javascript/images and imported along with the application.scss file into the app/javascript/packs/application.js for Webpack to manage those files for us. Here also rails-ujs gets initialized.

require("@rails/ujs").start()

import "../stylesheets/application.scss"
import "../images/logo.png"

import "controllers"

For Rails to use the bundle files from Webpack, the Rails application HTML layout needs to be updated to use Webpack’s files. Open the file app/views/layout/application.html.erb and add the Webpack helpers to it.

<!DOCTYPE html>
<html>
  <head>
    <title>Rails To Do</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= javascript_pack_tag "application" %>
    <%= stylesheet_pack_tag "application" %>

  </head>

  <body>
    <%= yield %>
  </body>
</html>

From the React components, ToDoItem.js and ToDo.js let us copy the HTML template part into two Rails template app/views/todos/_todo.html.erb and app/views/todos/index.html.erb respectively but with few modifications. First, the React specific code must be replaced with Rails code.

<div class="ToDoItem" data-controller="todo-delete" data-target="todo-delete.item">
  <p class="ToDoItem-Text"><%= todo.name %></p>
  <%= button_to "-", todo_path(todo.id),
      method: :delete,
      remote: true,
      form: { data: { action: "ajax:success->todo-delete#successResult ajax:error->todo-delete#errorResult" } },
      class: "ToDoItem-Delete"
     %>
</div>

StimulusJS will use those attributes to interact and connect with the HTML DOM.

data-controller tells StimulusJS, which Javascript component (controller) to activate when that attribute is present in the DOM. data-target is a way to reference DOM nodes inside the StimulusJS controller, and data-action is the way to dispatch DOM events to the StimulusJS controller.

Right now, without a StimulusJS controller, those data attributes are kind of useless, but we are planning for the time when the controllers are in place.

Now let us do the same for React component ToDo.js, the HTML template code needs to be copied to /app/views/todos/index.html.erb, here is the modified version of it.

<div class="ToDo">
  <%= image_tag asset_pack_path("media/images/logo.png"), class: "Logo", alt: "Rails logo" %>
  <h1 class="ToDo-Header">Rails To Do</h1>
  <div class="ToDo-Container" data-controller="todo">
    <div class="ToDo-Content" data-target="todo.todos">
      <%= render @todos %>
    </div>

    <div class="ToDoInput">
      <%= form_with model: @todo, local: false,
        data: { action: "ajax:beforeSend->todo#validateSubmit ajax:error->todo#errorResult ajax:success->todo#successResult" } do |form| %>
        <%= form.text_field :name, data: { target: "todo.field" } %>
        <%= form.submit "+", class: "ToDo-Add" %>
      <% end %>
    </div>
  </div>
</div>

Before we continue, let us make a little detour here. Remember the failing test for TodosController due to a missing template? The template is now in place, so the test should not be failing anymore, rerun the tests and see it by yourself.

$ bin/rails test

# Running:

........

Finished in 0.355593s, 22.4976 runs/s, 36.5586 assertions/s.
8 runs, 11 assertions, 0 failures, 0 errors, 0 skips

It is time to add Javascript to the project. Let us start with the controller that helps to delete a To Do item. The file is app/javascript/controllers/todo_delete_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["item"]

  errorResult(event) {
    console.log(event.detail)
  }

  successResult(event) {
    event.preventDefault()
    this.itemTarget.remove()
  }
}

The next controller is the one that takes care of adding new To Do item. The file is app/javascript/controllers/todo_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["todos", "field"]

  errorResult(event) {
    console.log("error", event.detail)
  }

  successResult(event) {
    const response = event.detail[0]
    const todoHTML = document.createRange().createContextualFragment(response.html)

    this.todosTarget.prepend(todoHTML)
    this.fieldTarget.value = ""
  }

  validateSubmit(event) {
    if (this.fieldTarget.value === "") {
      event.preventDefault()
    }
  }
}

It has two functions, validatesSubmit, which is called on form submit, and validates the input to now allow empty To Do. The second one, successResult is called after the Ajax request is made, and it takes care to place the To Do HTML fragment in the DOM. The HTML To Do fragment is part of the server response.

The project is done. If you want to try it out, add a couple of seed records into db/seeds.rb file.

Todo.create(name: "clean the house")
Todo.create(name: "buy milk")

And seed the database with the following command.

$ bin/rails db:seed

Now point your browser to http://localhost:3400 and try the application.

The application is similar in terms of UI interaction, but in addition, it has a Backend that is not present in the original React application. It also has automated tests for models and controllers, and we can do a little better by adding a System Test. This kind of test automates the browser to “use” the application in specific scenarios.

To add a System test, create the file test/system/todos_test.rb and add the following content.

require "application_system_test_case"

class TodosTest < ApplicationSystemTestCase
  test "visit todos" do
    todos_count = Todo.count
    visit root_url

    assert_selector "h1", text: "Rails To Do".upcase
    assert_selector ".ToDoItem", count: todos_count
  end

  test "try to add an empty todo" do
    todos_count = Todo.count
    visit root_url

    fill_in "todo_name", with: ""
    click_button "+"

    assert_selector ".ToDoItem", count: todos_count
  end

  test "add a todo" do
    todo = "Add Tests"
    todos_count = Todo.count
    visit root_url

    fill_in "todo_name", with: todo
    click_button "+"

    assert_selector ".ToDoItem", count: todos_count + 1
    assert_selector ".ToDoItem", text: todo
  end

  test "delete a todo" do
    todo = todos(:todo)
    todos_count = Todo.count

    visit root_url
    todo_element = page.find ".ToDoItem", text: todo.name
    remove_button = todo_element.find ".ToDoItem-Delete"
    remove_button.click

    assert_selector ".ToDoItem", count: todos_count - 1
    refute_selector ".ToDoItem", text: todo.name
  end
end

To run the System test, you need to have the Chrome browser installed. Run the test using the following command.

$ bin/rails test:system

# Running:

Capybara starting Puma...
* Version 4.3.1 , codename: Mysterious Traveller
* Min threads: 0, max threads: 4
* Listening on tcp://127.0.0.1:51968
Capybara starting Puma...
* Version 4.3.1 , codename: Mysterious Traveller
* Min threads: 0, max threads: 4
* Listening on tcp://127.0.0.1:51971
....

Finished in 5.133107s, 0.7793 runs/s, 1.3637 assertions/s.
4 runs, 7 assertions, 0 failures, 0 errors, 0 skips

Final words

What I would like you to take from replicating this example is that sometimes there is no need to go all the way in with a separated frontend like React, Vue, or Svelte.

By using the HTML standard, the maturity of your framework, and a tool like StimulusJS you can archive the same “snappy” functionality without the mess of Javascript code from the time before frameworks.

Both libraries, rails-ujs and StimulusJS were developed within the Rails community, but the truth is that they do not depend on Rails, both can be used with any other backend/HTML template system.

You can find the sample code at https://github.com/mariochavez/rails-todo-2019