Learn プログ

-マナビメモ-

Riot.js Observable Rails 2 / 5 「Todoリストを表示する」

はじめに

Riot.js Redux Railsシリーズで作ったTodoアプリのRedux部分をObservableを使って書いた。 (Observerパターンとか全然わかってない。ReduxをObservableで置き換える意味があるかもわからない。なんとなく似ていると思ったのでやってみた。)

Riot.js Redux Railsで作ったTodoアプリと全く同じものを作成するのでコードが大分重なっている(Redux部分をObservableにしたのと、それに伴う変更以外全く同じ)が、今回の一連の記事だけ読んでも出来るように省略はしない。

環境構築

Todo Appの作成

最終的に完成したTodo App
https://github.com/atfeo/Riot_Observable_Rails

参考

Rails

todo-appタグをマウントするトップページを作成

$ rails g controller Top index

routes.rbを書き換える

# .config/routes.rb
Rails.application.routes.draw do
  root 'top#index'
end

次にindex.html.erbにタグを書く

# ./app/views/top/index.html.erb
<todo-app></todo-app>

index.js

まず以下のようにしてみた。

// ./client/src/index.js
import riot from 'riot';

class Store {
  constructor() {
    this.state = { tasks: [] };
    riot.observable(this);
  }

  getState() {
    return this.state;
  }

  setState(value) {
    this.state = Object.assign({}, this.state, value);
  }
}

const store = new Store();

document.addEventListener('DOMContentLoaded', () => {
  riot.mount('todo-app', { store });
});

stateを持つStoreクラスを作成し、インスタンスをobservableで監視出来るようにし、storeインスタンスを作成。作成されたobservableインスタンス(storeインスタンス)をtodo-appタグに渡す。

タグの作成

todo-appタグ

Todo Appのメインとなるtodo-appタグを作成。

<!-- ./client/src/tags/todo-app.tag -->
<todo-app>
  <task-list tasks={this.state.tasks}></task-list>

  <script>
    const actions = require('../actions.js')
    const store = this.opts.store

    // Reduxと違う部分
    store.on('*', () => {
      this.state = store.getState()
      this.update()
    })
  </script>
</todo-app>

Reduxのsubscribeではなくel.on(events, callback)を使い '*' で全てのイベントを監視するようにしてある。 todoのリストはtask-listタグで表示させる。

task-listタグ

<!-- ./client/src/tags/task-list.tag -->
<task-list>
  <ul>
    <li each={task in this.opts.tasks}>
      {task.name}
    </li>
  </ul>
</task-list>

RailsAPIサーバーを作成

RailsJSONを返すAPIサーバーを作成する。

Controller

まずターミナルで

$ rails g controller api/tasks --no-assets

作成したtasks_controller.rbを

# ./app/controllers/api/tasks_controller.rb
class Api::TasksController < ApplicationController
  def index
    @tasks = Task.all
  end
end

このようにし、次にroutes.rbに

# ./config.routes.rb
namespace :api, format: 'json' do
  resources :tasks
end

を追加。

Model

次にまたターミナルで

$ rails g model task name:string

日時_create_tasks.rbに

# ./db/migrate/日時_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[5.0]
  def change
    create_table :tasks do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end

のように , null: false (nameが空だと登録できないようにしている)を追加しターミナルで

$ rails db:migrate

する。また、task.rbにvalidationを追加(同じくnameが空にならないように。しかし、apiとして使う場合モデルのバリデーションは必要ないかもしれない。わからない。)。

# ./app/models/task.rb
class Task < ApplicationRecord
  validates :name, presence: true
end

次にseeds.rbファイルに

# ./db/seeds.rb
Task.create(name: 'Lesson outline')
Task.create(name: 'Record video')

を追加し、ターミナルで

$ rails db:seed

を実行しデータを登録しておく。

View(JSON)

次にJSONを返すindex.json.jbuilderを作成。

# ./app/views/api/tasks/index.json.jbuilder
json.tasks(@tasks) { |t| json.extract! t, :id, :name }

ターミナルで

$ foreman start

し、localhost:5000/api/tasksにアクセスすると {"tasks":[{"id":1,"name":"Lesson outline"},{"id":2,"name":"Record video"}]}が表示されている。

サーバーからの読み込み

actions.jsにサーバーからデータを読み込むためのメソッドを作成する。

// ./client/src/actions.js
module.exports = {
  loadTasks,
};

function loadTasks(store) {
  // redux-thunkのようなmiddlewareを使わなくても良い
  $.ajax({
    url: '/api/tasks.json',
    dataType: 'json',
    success: (res) => {
      // dispatchではなくtriggerでイベントをトリガー
      tasksloaded(store, res.tasks)
    },
    error: (xhr, status, err) => {
      console.log('/api/tasks.json', status, err.toString());
    },
  });
}

function tasksloaded(store, tasks) {
  store.trigger('TASKS_LOADED', { data: tasks });
}

index.jsにマウントするタグの読み込みを追加。
イベントがトリガーされるのを監視して処理する部分を追加。

// ./client/src/index.js
import './tags/todo-app.tag';
import './tags/task-list.tag';

// reducerが担っていた部分
store.on('TASKS_LOADED', (action) => {
  store.setState({ tasks: action.data });
});

todo-app.tagにtodo-appタグがマウントされた時にする処理を追加する。

// ./client/src/tags/todo-app.tag
this.on('mount', () => {
  actions.loadTasks(store)
})

ここで一度、ブラウザでlocalhost:5000を更新して確認。先程登録したデータが表示される。

ロード時のアニメーションをつける

参考動画に沿って擬似的にロード時間を設け、ロード時のアニメーションを表示出来るようにする。アニメーションはデータ読み込み後消えるようにする。

まず2秒間の擬似的なロード時間を設けるために、actions.jsを書き換える。

// ./client/src/actions.js
function loadTasks(store) {
    $.ajax({
      url: '/api/tasks.json',
      dataType: 'json',
      success: (res) => {
        // 書き換え
        setTimeout(() => {
          tasksloaded(store, res.tasks);
        }, 2000)
      },
      error: (xhr, status, err) => {
        console.log('/api/tasks.json', status, err.toString());
      },
    });
  };
}

参考動画のソースのloading.gifをapp/assets/imagesにおく。

次にloading-indicatorタグを作成。

<!-- ./client/src/tags/loading-indicator.tag -->
<loading-indicator>
  <img src="/assets/loading.gif" show={this.opts.loading}>
</loading-indicator>

loading-indicatorタグをtodo-appタグにマウント。

<!-- ./client/src/tags/todo-app.tag -->
<loading-indicator loading={this.state.isLoading}></loading-indicator>

loading-indicatorをマウントできるようにindex.jsで読み込む。

// ./client/src/index.js
import './tags/loading-indicator.tag';

状態の変化のためのメソッド(イベントのトリガー)を作成。

// ./client/src/actions.js
function toggleLoading(store, isLoading) {
  store.trigger('TOGGLE_LOADING', { data: isLoading });
}

イベントがトリガーされた時の処理をindex.jsに追加。

// ./client/src/index.js
store.on('TOGGLE_LOADING', (action) => {
  store.setState({ isLoading: action.data });
});

ここまででloading-indicatorの用意は出来たので、loadTasks()にイベントをトリガーする処理を追加。

// ./client/src/actions.js
function loadTasks(store) {
    // 追加
    toggleLoading(store, true);
    $.ajax({
      url: '/api/tasks.json',
      dataType: 'json',
      success: (res) => {
        setTimeout(() => {
          tasksloaded(store, res.tasks);
          // 追加
          toggleLoading(store, false);
        }, 2000)
      },
      error: (xhr, status, err) => {
        // 追加
        toggleLoading(store, false);
        console.log('/api/tasks.json', status, err.toString());
      },
    });
  };
}

ブラウザでlocalhost:5000を更新して確認。
まずloading-indicatorが表示され、2秒後にデータの表示とともにloading-indicatorが消える。

Reduxのdispatch(action)をel.trigger(event)、reducerの処理をel.on(event)にすることで、同じようなものができた。

Riot Observable Rails 3 / 3へ続く

Riot.js Observable Rails 1 / 5 「環境構築」

はじめに

Riot.js Redux Railsシリーズで作ったTodoアプリのRedux部分をObservableを使って書いた。 (Observerパターンとか全然わかってない。ReduxをObservableで置き換える意味があるかもわからない。なんとなく似ていると思ったのでやってみた。)

Riot.js Redux Railsで作ったTodoアプリと全く同じものを作成するのでコードが大分重なっている(Redux部分をObservableにしたのと、それに伴う変更以外全く同じ)が、今回の一連の記事だけ読んでも出来るように省略はしない。

環境構築

  • Riot.js Observable Rails 1 / 5 「環境構築」

Todo Appの作成

最終的に完成したTodo App
https://github.com/atfeo/Riot_Observable_Rails

参考

環境構築

Mac
Rails 5.0.0.1
ruby 2.3.1
riot 2.6.7

ターミナル

$ rails new riot_observable_rails
$ cd riot_observable_rails
$ mkdir client
$ cd client

clientディレクト

Package.json

$ npm init -y

これによりpackage.jsonが作成されるので

{
  "private": "true",
  "scripts": {
    "webpack-watch": "webpack -w"
  }
}

のように書き換える。 再びターミナルで

$ npm install --save-dev webpack tag-loader babel-core babel-loader babel-preset-es2015

同じく

$ npm install --save riot

webpackの設定

webpack.config.jsを作成し

// ./client/webpack.config.js
module.exports = {
  entry: './src/index.js',
  output: {
    path: '../app/assets/javascripts/webpack',
    filename: 'bundle.js',
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        query: {
          presets: ['es2015'],
        },
      },
      {
        test: /\.tag$/,
        loader: 'tag',
        exclude: /node_modules/,
      },
    ],
  },
};

そして

$ cd ..

で、アプリケーションのルートに戻っておく

Gemfile

Gemfileに

# Gemfile
gem 'foreman'

を追加し

$ bundle install

Procfile

アプリケーションのルートディレクトリにProcfileを作成

rails: rails s
webpack: npm --prefix client run webpack-watch

環境構築終わり。

次回からTodoアプリの作成。

Riot.js Observable Rails 2 / 5に続く

Riot.js Redux Rails 8 / 8 「チェックしてあるタスクを削除出来るようにする」

はじめに

フロントにRiot.jsとReduxを使いサーバーサイドにRailsを使い、初めに環境構築し、次にReduxの使い方の流れをみて、最終的に簡単なTodoAppを作成する。

環境構築

簡単な例でReduxの流れをみる

Todo Appの作成

最終的に完成したTodo App
https://github.com/atfeo/Riot_Redux_Rails

参考

Riot公式のTodoのようにチェックしてあるタスクを全て削除するボタンを作成する。

Rails

routes

まず一度に複数削除する場合、サーバー側のurlはどうすれば良いのかわからなかった。 試しに

# ./config/routes.rb
namespace :api, format: 'json' do
  resources :tasks do
    delete :del_tasks
  end
end

としたところ

$ rails routes

でルーティングを確認するとurlが
/api/tasks/:task_id/del_tasks(.:format)の様になった。
複数削除なので:task_idがいらないと思い、

# ./config/routes.rb
namespace :api, format: 'json' do
  resources :tasks do
    delete :del_tasks, on: :collection
  end
end

としたところ
/api/tasks/del_tasks(.:format)の様になったのでこれを使う。

controller

一度に複数削除する方法がわからないのでidを配列で受け取り一つずつ処理するようにした。

# ./app/controllers/api/tasks_controller.rb
def del_tasks
  ids = params[:ids]
  ids.each do |id|
    if Task.find(id).destroy
      head :no_content
    else
      head :unprocessable_entity
    end
  end
end

private

def task_params
  # idsを追加
  params.permit(:name, :isComplete, :ids)
end

サーバー側はこれで終了。

Del Tasksボタン

todo-app.tagにDel Tasksのbuttonタグをマウントさせ、処理のためのメソッドも追加。

// ./client/src/tags/todo-app.tag
</task-form>
// 追加
<button 
  class="del" 
  onclick={handleDeleteTasks}
  disabled={this.state.tasks.filter(complete).length == 0}
>
  Del Tasks X {this.state.tasks.filter(complete).length}
</button>
<div style="clear: both"></div>

// scriptに追加
complete(task) {
  return task.isComplete == true
}

handleDeleteTasks() {
  // 大変だった箇所。チェックしてあるタスクのidをどのように取得するか迷った。
  let ids = []
  // まずチェックされているタスクだけの配列を作成
  const comp = this.state.tasks.filter((task) => this.complete(task))
  // チェックされているタスクの数だけfor文で処理
  for (let i = 0; i < comp.length; i += 1) {
    // チェックされているタスクのidをidsに格納
    ids[i] = comp[i].id
  }
  store.dispatch(actions.deleteTasks(ids))
}

Del Tasksボタンをformの隣に配置するためにtask-formタグにstyleを加える。

// ./client/src/tags/task-form.tag
<style scoped>
  :scope form {
    float: left;
  }
</style>

先程決めたサーバーのURLに配列idsを送るメソッドなどをactions.jsに作成。

// ./client/src/actions.js
module.exports = {
  loadTasks,
  addTask,
  textExists,
  toggleComplete,
  // 追加
  deleteTasks,
};

function deleteTasks(ids) {
  return (dispatch) => {
    $.ajax({
      url: '/api/tasks/del_tasks',
      type: 'DELETE',
      dataType: 'json',
      data: { ids },
      success: () => {
        dispatch(deletedTasks(ids));
      },
      error: (xhr, status, err) => {
        dispatch(toggleLoading(false));
        console.log('/api/tasks/del_tasks', status, err.toString());
      },
    });
  };
}

function deletedTasks(ids) {
  return { type: 'DELETED_TASKS', data: ids };
}

一番大変だった箇所。どうやって削除後の新しい配列を作るかかなり迷った。

// ./client/src/index.js
case 'DELETED_TASKS': {
  // stateを直接いじってはいけないらしいのでコピー
  let newTasks = state.tasks.slice();
  // 配列で渡されたidの数だけfor文で繰り返す。
  for (let i = 0; i < action.data.length; i += 1) {
    // 配列で渡されたidから、配列中のインデックスを取得
    const taskIndex = newTasks.findIndex((task) => {
      return task.id == action.data[i];
    });
    // 取得したインデックスを利用しコピーした配列から直接、破壊的に削除。
    newTasks.splice(taskIndex, 1);
  }
  return Object.assign({}, state, { tasks: newTasks });
}

cssに以下を追加しDel Tasksボタンの色を変える。

/* ./app/assets/stylesheets/application.css */
.del {
   background-color: #d21073;
   border: 1px solid rgba(0,0,0,.2);
 }

ブラウザでlocalhost:5000を更新する。チェックの数によってDel Tasksボタンの数値が変わること、ボタンを押して削除出来ることを確認。

ここまででほぼ完成。ついでに一つずつ削除する処理も追加しておく。

Rails

Controller

# ./app/controllers/api/tasks_controller.rb
def destroy
  if Task.find(params[:id]).destroy
    head :no_content
  else
    head :unprocessable_entity
  end
end

Delボタンの追加

いつもの流れ。まずタグをtask-list.tagに追加。

// ./client/src/tags/task-list.tag
</label>
// 追加
<button 
  class="del"
  id={task.id}
  show={task.isComplete}
  onclick={handleClick}>
    Del
</button>

// scriptに追加
handleClick(e) {
  this.opts.handledeletetask(e.target.id)
}

// styleを追加
<style scoped>
  :scope .del {
    float: right;
  }
</style>

todo-app.tagにもろもろ追加。

// ./client/src/tags/todo-app.tag
<task-list 
  tasks={this.state.tasks}
  handlecheck={handleTaskCompletionChange}
  // 追加
  handledeletetask={handleDeleteTask}>
</task-list>

// scriptに追加
handleDeleteTask(id) {
  store.dispatch(actions.deleteTask(id))
}

サーバーと通信するメソッドなどをactions.jsに追加。

// ./client/src/actions.js
module.exports = {
  loadTasks,
  addTask,
  textExists,
  toggleComplete,
  deleteTasks,
  // 追加
  deleteTask,
};

function deleteTask(id) {
  return (dispatch) => {
    dispatch(toggleLoading(true));
    $.ajax({
      url: `/api/tasks/${id}`,
      type: 'DELETE',
      dataType: 'json',
      success: () => {
        dispatch(deletedTask(id));
        dispatch(toggleLoading(false));
      },
      error: (xhr, status, err) => {
        dispatch(toggleLoading(false));
        dispatch(tempErrorMessage('API Error'));
        console.log('/api/tasks/del_tasks', status, err.toString());
      },
    });
  };
}

function deletedTask(id) {
  return { type: 'DELETED_TASK', data: id };
}

アクションを送られたときの処理をindex.jsに追加。

// ./client/src/index.js
case 'DELETED_TASK': {
  const newTasks = state.tasks.filter((task) => {
    return task.id != action.data;
  });
  return Object.assign({}, state, { tasks: newTasks });
}

ブラウザでlocalhost:5000を更新する。チェックしてあるタスクの右にDelボタンが表示されること、Delボタンを押して削除出来ることを確認。

Todoアプリ完成。