Learn プログ

-マナビメモ-

Riot.js Redux Rails 7 / 8 「完了したTodoをチェック出来るようにする」

はじめに

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

環境構築

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

Todo Appの作成

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

参考

checkbox

チェックボックスを作成。

// ./client/src/tags/task-list.tag
<task-list>
  <ul>
    <li each={task in this.opts.tasks}>
      // 追加
      <label class={completed: task.isComplete}>
        <input
          type="checkbox"
          id={task.id}
          checked={task.isComplete} onchange={handleCheck}
        >
        {task.name}
      </label>
    </li>
  </ul>

  <script>
    handleCheck(e) {
      this.opts.handlecheck(e.target.id, e.target.checked)
    }
  </script>
</task-list>

todo-app.tagにマウントされているtask-listタグに処理を追加。

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

handleTaskCompletionChange(id, isComplete) {
  store.dispatch(actions.toggleComplete(id, isComplete))
}

サーバーにチェックの状態を送るメソッドなどをactions.jsに追加。

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

function toggleComplete(id, isComplete) {
  return (dispatch) => {
    $.ajax({
      url: `/api/tasks/${id}`,
      type: 'PATCH',
      dataType: 'json',
      data: { isComplete },
      success: (res) => {
        dispatch(completeChanged(res.id, res.isComplete));
      },
      error: (xhr, status, err) => {
        console.log(`/api/tasks/${id}`, status, err.toString());
      },
    });
  };
}

function completeChanged(id, isComplete) {
  return {
    type: 'TASK_COMPLETION_CHANGED',
    data: { id, isComplete }
  };
}

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

// ./client/src/index.js
case 'TASK_COMPLETION_CHANGED': {
  const taskIndex = state.tasks.findIndex((task) => {
    return task.id == action.data.id;
  });
  const newTasks = [
    ...state.tasks.slice(0, taskIndex),
    Object.assign({}, state.tasks[taskIndex], { isComplete: action.data.isComplete }),
    ...state.tasks.slice(taskIndex + 1),
  ];
  return Object.assign({}, state, { tasks: newTasks });
}

チェックした時のスタイルを追加。

/* ./app/assets/stylesheets/application.css */
.completed {
  text-decoration: line-through;
  color: #ccc;
}

label {
  cursor: pointer;
}

ここまででviewは完成。

Rails

Migration

サーバー側でisCompleteを扱えるようにする。 ターミナルでctrl-cでforemanを終了させてから

$ rails g migration addIsCompleteToTasks isComplete:boolean

できたマイグレーションファイルに , default: false を追加し

# ./db/migrate/日付_add_is_complete_to_tasks.rb
class AddIsCompleteToTasks < ActiveRecord::Migration[5.0]
  def change
    add_column :tasks, :isComplete, :boolean, default: false
  end
end

マイグレーションを反映させる。

$ rails db:migrate

これでデータベースのテーブルにisCompleteカラムが追加された。

Controller

次にcontrollerでupdateについての処理を書く。

# ./app/controllers/api/tasks_controller.rb
def update
  @task = Task.find(params[:id])
  @task.assign_attributes(task_params)
  if @task.save
    render :show, status: :ok
  else
    head :unprocessable_entity
  end
end

private

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

View(JSON)

JSONがisCompleteも返すようにindex.json.jbuilder、show.json.jbuilderにisCompleteを追加。

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

再びターミナルで

$ foreman start

でサーバーを開始。

ブラウザでlocalhost:5000を更新してチェックしてもエラーにならないことを確認。
また更新後チェックの状態が維持されることも確認。

サーバーエラー時の処理

チェックの状態が変更された時にサーバー側でエラーが起きると、サーバー側とブラウザ側で矛盾が生じる(checkされているのにisCompleteがfalseまたはその逆)。それを防ぐために、エラー時にチェックを反転させる処理をactions.jsに書く。

// ./client/src/actions.js
function toggleComplete(id, isComplete) {
  return (dispatch) => {
    $.ajax({
      url: `/api/tasks/${id}`,
      type: 'PATCH',
      dataType: 'json',
      data: { isComplete },
      success: (res) => {
        dispatch(completeChanged(res.id, res.isComplete));
      },
      error: (xhr, status, err) => {
        // 追加
        dispatch(completeChanged(id, !isComplete));
        console.log(`/api/tasks/${id}`, status, err.toString());
      },
    });
  };
}

またこの時エラーが起こったことがわかりやすいように、エラーメッセージが表示されるようにする。
まずerror-messageタグを作成。

// ./client/src/tags/error-message.tag
<error-message>
  <div show={this.opts.iserror}>
    {this.opts.message}
  </div>

  <style scoped>
    :scope div {
      margin-top: 5px;
      padding-left: 10px;
      color: white;
      background-color: red;
    }
  </style>
</error-message>

todo-app.tagでerror-messageタグをマウントさせる。

// ./client/src/tags/todo-app.tag
</task-form>
<error-message
  message={"Text Message"}
  iserror={true}
>
</error-message>
<loading-indicator ...>

タグを使うためにindex.jsで読み込む。

// ./client/src/index.js
import './tags/error-message.tag';

ブラウザでlocalhost:5000を更新して Text Message というメッセージが表示されていることを確認。
エラーメッセージが表示できるようになったので、エラーが起こった時にだけ表示されるようにする。
showErrorメソッドを作成し、サーバーのエラー時の処理に追加。

// ./client/src/actions.js
error: (xhr, status, err) => {
  // 追加
  dispatch(showError('API Error'));
  dispatch(completeChanged(id, !isComplete));
  console.log(`/api/tasks/${id}`, status, err.toString());
},

// 追加
function showError(message) {
  return { type: 'SHOW_ERROR', data: message };
}

dispatchメソッドでアクションが送られたときの処理を追加。

// ./client/src/index.js
case 'SHOW_ERROR':
      return Object.assign({}, state, { isError: true, errorMessage: action.data });

先程のように固定ではなく、stateの変化に対応できるよう書き換え。

// ./client/src/tags/todo-app.tag
<error-message
  message={this.state.errorMessage}
  iserror={this.state.isError}
>
</error-message>

ここでわざとサーバーのurlを間違えたものにしておき、エラーが起こるようにしておく。
ブラウザでlocalhost:5000を更新する。チェックの状態を変更するとエラーが起こりエラーメッセージが表示されること、チェックが反転されることを確認。
次にエラーメッセージが2秒後消えるようにする。

// ./client/src/actions.js
error: (xhr, status, err) => {
  // 書き換え
  dispatch(tempErrorMessage('API Error'));
  dispatch(completeChanged(id, !isComplete));
  console.log(`/api/tasks/${id}`, status, err.toString());
},

// 追加
function hideError() {
  return { type: 'HIDE_ERROR' };
}

function tempErrorMessage(message) {
  return (dispatch) => {
    dispatch(showError(message));
    setTimeout(() => {
      dispatch(hideError());
    }, 2000);
  };
}

新たなアクションの処理を追加。

// ./client/src/index.js
case 'HIDE_ERROR':
      return Object.assign({}, state, { isError: false, errorMessage: '' });

ブラウザでlocalhost:5000を更新する。チェックの状態を変更するとエラーが起こりエラーメッセージが表示され2秒後に消えること、チェックが反転されることを確認。

一応loadTasks()やaddTask()などサーバーと通信している他のメソッドにも

error: (xhr, status, err) => {
  dispatch(toggleLoading(false));
  // 追加
  dispatch(tempErrorMessage('API Error'));

このように追加して、エラーメッセージが表示されるようにしておく。

次回はTodoを削除できるようにする。

Riot.js Redux Rails 8 / 8に続く

Riot.js Redux Rails 6 / 8 「新たなTodoを追加出来るようにする」

はじめに

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

環境構築

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

Todo Appの作成

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

参考

Rails

新たなTodoを追加する際のサーバー側の処理を作成する。

Controller

tasks_controller.rbに

# ./app/controllers/api/tasks_controller.rb
def create
  @task = Task.new(task_params)
  if @task.save
    render :show, status: :created
  else
    head :unprocessable_entity
  end
end

private

def task_params
  params.permit(:name)
end

を追加。

View(JSON)

保存に成功したときにJSON返すshow.json.jbuilderを作成。

# ./app/views/api/tasks/show.json.jbuilder
json.extract! @task, :id, :name

これでサーバー側は完成。

Todoを追加するためのtask-formタグ

UIをRiot公式のTodoのように作成する。 + フォームに入力があった時にボタンが有効になるようにする。 + 何個目のTodoを登録しようとしているかわかるようにする。

// ./client/src/tags/task-form.tag
<task-form>
  <form onsubmit={handleSubmit}>
    <input
      type="text"
      name="newTask"
      onkeyup={handleKeyup}
      placeholder="new task"
    >
    <button
      type="submit"
      disabled={!this.opts.istext}
    >
      Add Task # {this.opts.objects.length + 1}
    </button>
  </form>

  <script>
    handleSubmit() {
      if (!this.newTask.value) {
        return
      }

      this.opts.addtask(this.newTask.value)
      this.newTask.value = ''
    }

    handleKeyup() {
      this.opts.handlekeyup(this.newTask.value)
    }
  </script>
</task-form>

サーバーに新しいタスクを送るメソッドなどをactions.jsに追加。

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

// 追加
function addTask(newTask) {
  return (dispatch) => {
    dispatch(toggleLoading(true));
    $.ajax({
      url: '/api/tasks.json',
      type: 'POST',
      dataType: 'json',
      data: { name: newTask },
      success: (res) => {
        dispatch(newTaskAdded(res.id, res.name));
        dispatch(toggleLoading(false));
      },
      error: (xhr, status, err) => {
        dispatch(toggleLoading(false));
        console.log('/api/tasks.json', status, err.toString());
      },
    });
  };
}

function newTaskAdded(id, name) {
  return { type: 'TASK_ADDED', data: { id, name } };
}

function textExists(value) {
  return { type: 'TEXT_EXISTS', data: value };
}

タグをマウントするための処理と、アクションを送られたときの処理をindex.jsに追加。

// ./client/src/index.js
import './tags/task-form.tag';

// reducerに追加
case 'TASK_ADDED':
      return Object.assign({}, state, { tasks: state.tasks.concat(action.data) });
case 'TEXT_EXISTS':
      return Object.assign({}, state, { isText: action.data });

todo-appタグにtask-formタグ、h3タグ、メソッドを追加

<!-- ./client/src/tags/todo-app.tag -->
<todo-app>
  <h3>Todo List</h3>
  <task-form
    addtask={this.handleNewTask}
    handlekeyup={handleInputForm}
    objects={this.state.tasks}
    istext={this.state.isText}>
  </task-form>
  <loading- ...>

  // scriptに追加
  handleNewTask(task) {
    store.dispatch(actions.addTask(task))
  }

  handleInputForm(value) {
    store.dispatch(actions.textExists(value))
  }

CSSの追加

cssを追加しUIをRiot公式のTodoに近づける。

/* ./app/assets/stylesheets/application.css */
body {
 font-family: 'myriad pro', sans-serif;
 font-size: 20px;
 border: 0;
}

todo-app {
  display: block;
  max-width: 600px;
  margin: 5% auto;
}

form input {
  font-size: 85%;
  padding: .4em;
  border: 1px solid #ccc;
  border-radius: 2px;
}

button {
  background-color: #1FADC5;
  border: 1px solid rgba(0,0,0,.2);
  font-size: 75%;
  color: #fff;
  padding: .4em 1.2em;
  border-radius: 2em;
  cursor: pointer;
  margin: 0 .23em;
  outline: none;
}

button[disabled] {
  background-color: #ddd;
  color: #aaa;
}

ul {
  padding: 0;
}

li {
  list-style-type: none;
  padding: .2em 0;
}

ブラウザでlocalhost:5000を更新して確認。
フォームにタスクの名前を入力しボタンを押して登録できること、更新後も登録したTodoが維持されていることを確認。

次回は完了したTodoをチェック出来るようにする。

Riot.js Redux Rails 7 / 8に続く

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

はじめに

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

環境構築

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

Todo Appの作成

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

参考

準備

今まで作ってきたsample-outputタグ、title-formタグは使わないのでindex.html.erbから

# ./app/views/top/index.html.erb
<sample-output></sample-output>
<title-form></title-form>

これらを削除し空にする。またindex.jsから

// ./client/src/index.js
import './tags/sample-output.tag';
import './tags/title-form.tag';

title: 'Default title'

'CHANGE_TITLE':
      return Object.assign({}, state, { title: action.data });

も削除。するとこうなる

// ./client/src/index.js
import riot from 'riot';
import { createStore } from 'redux';
 
function reducer(state = {  }, action) {
  switch (action.type) {
    case 
    default:
      return state;
  }
}

const reduxStore = createStore(reducer);
 
document.addEventListener('DOMContentLoaded', () => {
  riot.mount('*', { store: reduxStore });
});

またactions.jsから

// ./client/src/actions.js
changeTitle,

function changeTitle(newTitle) {
  return { type: 'CHANGE_TITLE', data: newTitle };
}

も削除。するとこうなる

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

これで準備完了。

RailsAPIサーバーを作成

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

ターミナルでctrl-cを入力しforemanを一時終了。

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

indexアクションを追加し、次に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"}]}が表示されている。

redux-thunk

(redux-thunkについては「なんか非同期処理に必要なんだな」ぐらいの理解しかない。)

redux-thunkを使うための準備としてindex.jsを以下のようにする。

// ./client/src/index.js
// 追加
import thunk from 'redux-thunk';

// 書き換え
import { createStore, applyMiddleware } from 'redux';

const reduxStore = createStore(
  reducer,
  applyMiddleware(thunk)
);

todo-appタグ

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

<!-- ./client/src/tags/todo-app.tag -->
<todo-app>

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

    store.subscribe(() => {
      this.state = store.getState()
      this.update()
    })
  </script>
</todo-app>

todoのリストはtask-listタグで表示させる。

// ./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

    store.subscribe(() => {
      this.state = store.getState()
      this.update()
    })
  </script>
</todo-app>

task-listタグ

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

サーバーからの読み込み

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

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

function loadTasks() {
  return (dispatch) => {
    $.ajax({
      url: '/api/tasks.json',
      dataType: 'json',
      success: (res) => {
        dispatch(tasksloaded(res.tasks));
      },
      error: (xhr, status, err) => {
        console.log('/api/tasks.json', status, err.toString());
      },
    });
  };
}

function tasksloaded(tasks) {
  return { type: 'TASKS_LOADED', data: tasks };
}

(npmでjQueryをインストールしていないがRailsに入っているので良きに計らってくれると思い、ajaxをそのまま書いた)

タグをマウントするための処理と、アクションを送られたときの処理をindex.jsに書く。

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

// 書き換え
function reducer(state = { tasks: [] }, action) {
  switch (action.type) {
    case 'TASKS_LOADED':
      return Object.assign({}, state, { tasks: action.data });
    default:
      return state;
  }
}

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

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

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

タグがマウントされた時にloadTasks()が実行されるようにtodo-appタグのscriptに以下を追加。

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

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

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

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

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

// ./client/src/actions.js
function loadTasks() {
  return (dispatch) => {
    $.ajax({
      url: '/api/tasks.json',
      dataType: 'json',
      success: (res) => {
        // 書き換え
        setTimeout(() => {
          dispatch(tasksloaded(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>
<task-list

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

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

状態の変化のためのメソッド(アクション)を作成。

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

アクションを送られた時の処理をreducerに追加。

// ./client/src/index.js
case 'TOGGLE_LOADING':
      return Object.assign({}, state, { isLoading: action.data });

ここまででloading-indicatorの用意は出来たので、loadTasks()にdispatchメソッドでアクションを送る処理を追加。

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

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

サーバーからデータを読み込んで表示することが出来た。
次回はフォームを作成し、新しくタスクを追加できるようにする。

Riot.js Redux Rails 6 / 8に続く