Nous avons vu dans l'article précédent comment créer une application web avec Rails 5 et ReactJs interprété côté serveur. Dans cet article, nous allons couvrir comment créer une SPA (Single Page Application) en ReactJs et Rails 5 pour API.

Il serait possible d'utiliser le travail réalisé lors des deux derniers articles, mais je considère que le mode de fonctionnement étant radicalement différent, il est préférable de commencer le projet de zéro bien que le code ReactJs sera réutilisé.

Prérequis

Les prérequis (Ruby 2.4.1, NodeJs 6.4, Rails 5.1.0, bundler, yarn et foreman) sont les mêmes que pour l'article initial.

nvm install 6.4
nvm use 6.4
npm install --global yarn
rvm install 2.4.1
rvm use 2.4.1
gem install rails --version=5.1.0
gem install bundler foreman

Création du projet

Pour réaliser notre projet, nous allons passer les commandes suivantes.

rails _5.1.0_ new mon-projet --api --webpack=react 
cd mon-projet
npm install html-webpack-plugin --save-dev
yarn add react-router react-router-dom react-modal

Rails

Décomposons ce qui vient de se passer avec la commande rails _5.1.0_ new mon-projet --api --webpack=react. - l'option --api configure rails en mode API ; les vues sont retirées et les contrôleurs sont héritent de ActionController::API - --webpack=react installe automatiquement la gem webpacker et la configure pour ReactJs (c'est pourquoi il faut avoir yarn d'installé avant)

Plugin Webpack

La commande npm install html-webpack-plugin --save-dev ajoute le plugin html-webpack à notre configuration nous permettant de générer un fichier index.html contenant tous les appels à nos JavaScripts et StyleSheet. Pour finaliser cette installation, nous devons modifier notre fichier config/webpack/development.js tel que ci-dessous. Pour les détails, consultez leur repo sur github.

const environment = require('./environment')
const HtmlWebpackPlugin = require('html-webpack-plugin')

environment.plugins.set('HtmlWebpackPlugin', new HtmlWebpackPlugin({
  title: 'Mon projet',
  inject: true
}))
module.exports = environment.toWebpackConfig()

A chaque fois que le webpacker sera appelé, il générera un fichier index.html dans /public/packs

Paquets React

La commande yarn add react-router react-router-dom react-modal installe les paquets dont nous avons besoin afin de de passer d'une page à une autre sans requête serveur (react-router) et d'afficher un modal (react-modal) lors de la création d'un nouvel Item.

Foreman

Il faut configurer Foreman tel que dans le premier article :

Pour ce faire, je crée deux fichiers : Procfile.dev et .foreman.

# Procfile.dev
web: bundle exec rails s -p $PORT -b $IP
webpacker: ./bin/webpack-dev-server --inline true --hot true --public $C9_HOSTNAME
#.foreman
procfile: Procfile.dev

Le code javascript

Dans le répertoire app/javascript je crée les fichiers suivants :

- javascript
  - components
    - item
      - Item.js
      - ItemList.js
      - ItemsHome.js
      - NewItem.js
    - App.js
    - Home.js
  - packs
  - hello_react.jsx

Dans l'idée, hello_react est appelé par le fichier index.html et propose deux liens. L'un de ces liens pointe vers ItemsHome qui affiche la liste des objets existants et propose d'en ajouter un via NewItem.

Le composant Item

Il décrit l'affichage d'un Item.

// components/item/Item.js
import React from "react"
import PropTypes from "prop-types"

class Item extends React.Component {
  render () {
    return (
      <div>
        <div>Title: {this.props.title}</div>
        <div>Description: {this.props.description}</div>
        <div>Is Done: {this.props.isDone}</div>
      </div>
    );
  }
}

Item.propTypes = {
  title: PropTypes.string,
  description: PropTypes.string,
  isDone: PropTypes.bool
};
export default Item

Le composant ItemList

Il permet de lister les Item passés en argument.

// javascript/components/item/ItemList.js
import React from "react"
import PropTypes from "prop-types"
import Item from "./Item"

class ItemList extends React.Component {
  render () {
    return (
      <div>
        {
          this.props.items.map(function(item){
            const id = "item-"+item.id
            return (
              <div key={id}>
                <Item title={item.title} description={item.description} isDone={item.isDone} />
              </div>
            )
          })
        }
      </div>
    );
  }
}

ItemList.propTypes = {
  items: PropTypes.array
};

export default ItemList

Le composant NewItem

Il va permettre de créer un nouvel item. Il prend en paramètre le composant parent (ce sera ItemHome) afin de déclencher le rafraîchissement de la liste des items lors du retour valide d'une création par l'API.

// javascript/components/item/NewItem.js
import React from "react"
import PropTypes from "prop-types"
import axios from 'axios';

class NewItem extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      title: "",
      description: ""
    };

    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleInputChange(event) {
    const target = event.target;
    const value = target.value;
    const name = target.name;

    this.setState({[name]: value});
  }

  handleSubmit(event) {
    event.preventDefault();
    axios.post("/items", {
      title: this.state.title,
      description: this.state.description
    }).then( response => {
      this.props.parent.refresh();
      this.props.parent.closeModal();
    }).catch(function (error) {
      console.log(error.response.data.error);
    });
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit} >
        <label>
          Title :
          <input
            name="title"
            type="text"
            value={this.state.title}
            onChange={this.handleInputChange} />
        </label>
        <br />
        <label>
          Description :
          <input
            name="description"
            type="text"
            value={this.state.description}
            onChange={this.handleInputChange} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

NewItem.propTypes = {
  parent: PropTypes.object
};

export default NewItem

Composant ItemHome

Ce composant intègre un modal qui à son affichage (componentDidMount) déclenche une requête au serveur et modifie l'objet items de son état (this.setState({items: res.data});). Il introduit aussi un Modal dans lequel est ajouté le composant NewItem que nous venons de créer.

Dans le constructeur, la fonction refresh est liée (this.refresh = this.refresh.bind(this)) afin de permettre au composant enfant NewItem de l’appeler et d'ainsi rafraîchir la liste.

// javascript/components/item/ItemsHome.js

import React from "react"
import axios from 'axios';
import { Route, Link } from 'react-router-dom';
import ItemList from "./ItemList"
import NewItem from "./NewItem"
import Modal from 'react-modal';

class ItemsHome extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      items: [],
      modalIsOpen: false
    };

    this.openModal = this.openModal.bind(this);
    this.closeModal = this.closeModal.bind(this);
    this.refresh = this.refresh.bind(this);
  }
  openModal() {
    this.setState({modalIsOpen: true});
  }
  closeModal() {
    this.setState({modalIsOpen: false});
  }
  render () {
    return (
      <div>
        <div>
          <div>
            <button onClick={this.openModal}>Nouveau</button>
            <Modal
              isOpen={this.state.modalIsOpen}
              onRequestClose={this.closeModal}
              contentLabel="Modal de création" >
              <h2>Création d'un nouvel Item</h2>
              <button onClick={this.closeModal}>Fermer</button>
              <NewItem parent={this}/>
            </Modal>
          </div>
        </div>
        <ItemList items={this.state.items} />
      </div>
    );
  }
  componentDidReceiveProps(newProps){
    this.refresh();
  }
  componentDidMount() {
    this.refresh();
  }
  refresh(){
    axios.get("/items")
      .then( res => {
        this.setState({items: res.data});
      });
  }
}

export default ItemsHome

Le composant Home

Il ne manque plus à l'application que le composant Home qui n'affichera qu'un bonjour (mais permet de tester l'utilisation de routes) et App qui sera le point d'entrer de l'application et liera tous les composants.

// javascript/components/Home.js

import React from "react"

class Home extends React.Component {
  render () {
    return (
      <div>
        <h2>Page d'accueil</h2>
        Bonjour.
      </div>
    );
  }
}

export default Home

Le composant App

C'est le composant qui lie les autres. App permet de comprendre comment fonctionne la navigation dans l'application. En effet, tout le code est situé à l’intérieur du composant BrowserRouter.

Les composants NavLink permettent de savoir quel est le dernier lien sélectionné, via l'attribut activeClassName, il est possible de spécifier une classe lorsque le lien est actif.

Le composant Switch regroupe des Route et permet de n'afficher que celui qui correspond au path demandé.

// javascript/components/App.js

import React from "react"
import { BrowserRouter, Switch, Route, NavLink } from 'react-router-dom';
import Home from './Home'
import ItemsHome from './item/ItemsHome'

class App extends React.Component {

  render () {
    return (
      <BrowserRouter>
        <div>
          <h2>Une petite Single Page Application (SPA) ReactJs</h2>
          <ul>
            <li><NavLink to={'/'} activeClassName="active">Home</NavLink></li>
            <li><NavLink to={'/items'} activeClassName="active">Items</NavLink></li>
          </ul>
          <Switch>
            <Route exact path='/' component={Home} />
            <Route path='/items' component={ItemsHome} />
          </Switch>
        </div>
      </BrowserRouter>
    );
  }
}

export default App

Le script d'appel

Il va être injecté par html-webpack-plugin dans le fichier index.html et appelle le composant App.

// javascript/packs/hello_react.js

import React from 'react'
import ReactDOM from 'react-dom'
import App from 'components/App'

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <App />,
    document.body.appendChild(document.createElement('div'))
  )
})

Rails

Notre Single Page Application est maintenant prête ; il ne reste que l'API à écrire.

rails g resource item title:string description:string is_done:boolean
rails db:migrate RAILS_ENV=test

Le contrôleur doit permettre de retourner la liste des items et d'en créer.

# app/controllers/items_controller.rb

class ItemsController < ApplicationController
  def index
    render json: Item.all
  end

  def create
    item = Item.new(item_params)
    if item.save
      render json: item
    else 
      render json: {error: item.errors.full_messages}, status: 422
    end
  end

  private
    def item_params
      params.permit(:title, :description, :is_done)
    end
end

Tests unitaires

La partie test unitaire ne change pas de l'article précédent ; dans le fichier test/models/item_test.rb je rajoute les tests suivants.

# test/models/item_test.rb
require 'test_helper'

class ItemTest < ActiveSupport::TestCase
  def setup
    @parameters = { title: "title", description: "a super description" }
  end

  test "it should not be created without title" do
    @parameters.reject! {|k, v| k == :title}
    assert_raises (ActiveRecord::RecordInvalid){ Item.create!(@parameters) }
  end

  test "it should not be created without description" do
    @parameters.reject! {|k, v| k == :description}
    assert_raises (ActiveRecord::RecordInvalid) { Item.create!(@parameters) }
  end

  test "it should not be done at creation" do
    @parameters.merge!({ is_done: true })
    item = Item.create!(@parameters)
    assert_not item.is_done
  end

  test "it should be created with full informations" do
    item = Item.create!(@parameters)
    assert item.valid?
    assert_not item.is_done
  end
end

Et le modèle pour qu'il soit valide.

# app/models/items.rb
class Item < ApplicationRecord
  validates_presence_of :title
  validates_presence_of :description
  before_create :init_attributes

  private

    def init_attributes
      self.is_done = false
    end
end

Tests d'intégration avec cucumber

Afin de tester l'application avec cucumber, il faut modifier certains éléments de la configurations.

Configuration

Tout d'abord, il faut ajouter la gem selenium-webdriver afin de pouvoir executer le javascript lors des tests.

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'cucumber-rails', :require => false
  gem 'selenium-webdriver'
  gem 'database_cleaner'
end

Ensuite, dans test.js il faut ajouter le plugin HtmlWebpackPlugin tel que ci-dessous.

// congig/webpack/test.js
const environment = require('./environment')
const HtmlWebpackPlugin = require('html-webpack-plugin')

environment.plugins.set('HtmlWebpackPlugin', new HtmlWebpackPlugin({
  title: 'My ReactJs App',
  inject: true
}))
module.exports = environment.toWebpackConfig()

Et pour finir la configuration des tests, il faut créer le fichier features/support/webpacker.rb

#features/support/webpacker.rb
module WebpackerTestSupport
  module_function def compile_once
    digest_file = Rails.root.join("tmp/webpacker_#{Rails.env}_digest")

    # Compute hash of all packable assets
    packable_contents = Dir[Webpacker.config.source_path.join('**/*')]
      .sort
      .map { |filename| File.read(filename) if File.file?(filename) }
      .join
    digest = Digest::SHA256.hexdigest(packable_contents)

    # Do nothing if assets did not change
    return if digest_file.exist? && digest_file.read == digest

    if ENV['TEST_ENV_NUMBER'].to_i < 1
      # Remove any previously compiled files
      output_path = Webpacker.config.public_output_path
      FileUtils.rm_r(output_path) if File.exist?(output_path)
      puts "Removed Webpack output directory #{output_path}"

      # Ask 1st worker to compile assets
      Webpacker.compile

      digest_file.write(digest)
    else
      loop do
        # Other parallel test workers wait until 1st worker has compiled assets
        break if digest_file.exist? && digest_file.read == digest
        sleep 0.1
      end
    end
  end
end

WebpackerTestSupport.compile_once

Features

La définition des étapes doit pointer vers le répertoire public/pack-test dés lors que le test veut visiter une page. C'est donc visit "packs-test/index.html" qui est utilisé pour la page index plutôt que visit "items#index".

#features/step_definitions/items_steps.rb
Given(/^the following items exist:$/) do |table|
  # table is a Cucumber::MultilineArgument::DataTable
  table.hashes.each do |obj|
    Item.create!(obj)
  end
end

Given(/^I am on the items index$/) do
  visit "packs-test/index.html"
end

De plus, il faut aussi ajouter le marqueur @javascript dans les features lorsqu'il est necessaire d'interpréter le javascript.

# todo_list/features/item.feature
Feature: Managing items
  As a user
  So that I can go to shopping 
  I want to see my shopping list

Background:
  Given the following items exist:
  |id|title |description|is_done|
  |1 |Item 1|bla        |false  |
  |2 |Item 2|blabla     |false  |

@javascript
Scenario: the view should show the shopping list
  Given I am on the items index
  Then I should see "Item 1"

Build

À partir de maintenant, il est possible de lancer les serveurs foreman start et de voir l'application sur le navigateur à l'adresse packs/index.html. Il est aussi désormé possible de générer un packet pour une intégration dans Cordova à l'aide de la commande ./bin/webpack qui va créer dans public/packs une SPA.

$ ./bin/webpack
Hash: 6a579d0a891f28af904c
Version: webpack 3.8.1
Time: 3757ms
                                   Asset       Size  Chunks                    Chunk Names
server_rendering-0bb15dedc5728faa11da.js    2.65 MB       0  [emitted]  [big]  server_rendering
     application-81f6ed9e4b71de9ad6d8.js    2.66 MB       1  [emitted]  [big]  application
     hello_react-0529a5002918b4b61c2a.js    2.39 MB       2  [emitted]  [big]  hello_react
                           manifest.json  210 bytes          [emitted]         
                              index.html  401 bytes          [emitted]         
 [115] ./app/javascript/components ^\.\/.*$ 372 bytes {0} {1} [built]
 [128] ./app/javascript/packs/application.js 730 bytes {1} [built]
 [129] ./app/javascript/packs/hello_react.jsx 496 bytes {2} [built]
 [130] ./app/javascript/packs/server_rendering.js 299 bytes {0} [built]
    + 127 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
       [2] (webpack)/buildin/global.js 488 bytes {0} [built]
       [3] (webpack)/buildin/module.js 495 bytes {0} [built]
        + 2 hidden modules

Conclusion

Cet article a couvert les bases d'une Application SPA écrite en ReactJs totalement intégré au développement du serveur en Rails. J'espère qu'il aide à mieux cerner une intégration SPA/Rails gràce à Webpack.

Publié dans les catégories suivantes

javascriptruby
comments powered by Disqus

Téléphone

+33 637 700 504

Adresse

Bordeaux, 33300
France