Learn プログ

-マナビメモ-

jQueryで書いたAjaxを、Fetch APIで書く

はじめに

今まで作ってきたTodoApp(3つのシリーズ)のサーバーとの通信部分を、RailsAPIサーバーとして完全に切り離した時を想定して(Riot.jsやReactを使ったときはjQueryは使わないだろうから)、jQueryからFetch APIを使ったものにしてみた。

参考

注意

csrfTokenはAPIサーバーとして切り離した時は関係ない。 そのかわり、corsに対応するため

fetch(url, {
  mode: 'cors', 
  credentials: 'include' 
})

が必要。credentialsはCookieやBASIC 認証などの認証情報。

loadTasks()

jQuery

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));
        dispatch(tempErrorMessage('API Error'));
        console.log('/api/tasks.json', status, err.toString());
      },
    });
  };
}

Fetch API

まずサーバー側のエラーを直接はキャッチ出来ないらしいので、下のようにチェックしてエラーを投げる。 今後全てのメソッドに使う。

function checkStatus(response) {
  if (response.status >= 200 && response.status < 300) {
    return response;
  } else {
    var error = new Error(response.statusText);
    error.response = response;
    throw error;
  }
}
function loadTasks() {
  return (dispatch) => {
    const csrfToken = document.getElementsByName('csrf-token').item(0).content;
    dispatch(toggleLoading(true));
    fetch('/api/tasks.json', {
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,
      },
    }).then((response) => {
      // ここでエラーチェック
      checkStatus(response);
      return response.json();
    }).then((json) => {
      setTimeout(() => {
        dispatch(tasksloaded(json.tasks));
        dispatch(toggleLoading(false));
      }, 2000)
    }).catch((err) => {
      dispatch(toggleLoading(false));
      dispatch(tempErrorMessage('API Error'));
      console.error(err);
    });
  };
}

addTask()

jQuery

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));
        dispatch(tempErrorMessage('API Error'));
        console.log('/api/tasks.json', status, err.toString());
      },
    });
  };
}

Fetch API

function addTask(newTask) {
  return (dispatch) => {
    const csrfToken = document.getElementsByName('csrf-token').item(0).content;
    dispatch(toggleLoading(true));
    fetch('/api/tasks.json', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,
      },
      body: JSON.stringify({ name: newTask }),
    }).then((response) => {
      // ここでエラーチェック
      checkStatus(response)
      return response.json();
    }).then((json) => {
      dispatch(newTaskAdded(json.id, json.name));
      dispatch(toggleLoading(false));
    }).catch((err) => {
      dispatch(toggleLoading(false));
      dispatch(tempErrorMessage('API Error'));
      console.error(err);
    });
  };
}

toggleLoading()

jQuery

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(tempErrorMessage('API Error'));
        dispatch(completeChanged(id, !isComplete));
        console.log(`/api/tasks/${id}`, status, err.toString());
      },
    });
  };
}

Fetch API

function toggleComplete(id, isComplete) {
  return (dispatch) => {
    const csrfToken = document.getElementsByName('csrf-token').item(0).content;
    fetch(`/api/tasks/${id}`, {
      method: 'PATCH',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,
      },
      body: JSON.stringify({ isComplete }),
    }).then((response) => {
      // ここでエラーチェック
      checkStatus(response)
      return response.json();
    }).then((json) => {
      console.log(json);
      dispatch(completeChanged(json.id, json.isComplete));
    }).catch((err) => {
      dispatch(toggleLoading(false));
      dispatch(tempErrorMessage('API Error'));
      console.error(err);
    });
  };
}

deleteTasks()

jQuery

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

Fetch API

function deleteTasks(ids) {
  return (dispatch) => {
    const csrfToken = document.getElementsByName('csrf-token').item(0).content;
    dispatch(toggleLoading(true));
    fetch('/api/tasks/del_tasks', {
      method: 'DELETE',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,
      },
      body: JSON.stringify({ ids }),
    }).then((response) => {
      // ここでエラーチェック
      checkStatus(response)
    }).then(() => {
      dispatch(deletedTasks(ids));
      dispatch(toggleLoading(false));
    }).catch((err) => {
      dispatch(toggleLoading(false));
      dispatch(tempErrorMessage('API Error'));
      console.error(err);
    });
  };
}

deleteTask()

jQuery

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(tempErrorMessage('API Error'));
        dispatch(toggleLoading(false));
        console.log('/api/tasks/del_tasks', status, err.toString());
      },
    });
  };
}

Fetch API

function deleteTask(id) {
  return (dispatch) => {
    const csrfToken = document.getElementsByName('csrf-token').item(0).content;
    dispatch(toggleLoading(true));
    fetch(`/api/tasks/${id}`, {
      method: 'DELETE',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,
      },
    }).then((response) => {
      // ここでエラーチェック
      checkStatus(response)
    }).then(() => {
      dispatch(deletedTask(id));
      dispatch(toggleLoading(false));
    }).catch((err) => {
      dispatch(toggleLoading(false));
      dispatch(tempErrorMessage('API Error'));
      console.error(err);
    });
  };
}

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

はじめに

Riot.js Redux Railsシリーズで作ったTodoアプリのRiot.js部分をReactを使って書いた。

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

環境構築

Todo Appの作成

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

参考

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

Rails

routes

まず複数削除のためのルートを追加

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

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.jsxにDel Tasksのbuttonコンポーネントをマウントさせ、処理のためのメソッドも追加。

// ./client/src/components/todo-app.jsx
class TodoApp extends React.Component {
  constructor(props) {
    super(props);
    this.handleNewTask = this.handleNewTask.bind(this);
    this.handleInputForm = this.handleInputForm.bind(this);
    this.handleTaskCompletionChange = this.handleTaskCompletionChange.bind(this);
    // 追加
    this.handleDeleteTasks = this.handleDeleteTasks.bind(this);
  }

  complete(task) {
    return task.isComplete == true;
  }

  handleDeleteTasks() {
    let ids = [];
    const comp = this.props.tasks.filter((task) => this.complete(task));
    for (let i = 0; i < comp.length; i += 1) {
      ids[i] = comp[i].id;
    }
    this.props.dispatch(actions.deleteTasks(ids));
  }


    <button
      className="del"
      onClick={this.handleDeleteTasks}
      disabled={this.props.tasks.filter(this.complete).length == 0}
    >
      Del Tasks X {this.props.tasks.filter(this.complete).length}
    </button>
    <div style={{ clear: "both" }} />
    {this.props.isError ...

Del Tasksボタンをformの隣に配置するためにtask-formコンポーネントにstyleを加える。

// ./client/src/components/task-form.jsx
render() {
    return (
      <form
        onSubmit={this.handleSubmit}
        // 追加
        style={{ float: "left" }}
      >

先程決めたサーバーの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(tempErrorMessage('API Error'));
        dispatch(toggleLoading(false));
        console.log('/api/tasks/del_tasks', status, err.toString());
      },
    });
  };
}

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

アクションが渡された時の処理をindex.jsxのreducerに追加。

// ./client/src/index.jsx
case 'DELETED_TASKS': {
  let newTasks = state.tasks.slice();
  for (let i = 0; i < action.data.length; i += 1) {
    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ボタンの追加

まずDelボタンをtask-list.jsxに追加。

//  ./client/src/components/task-list.jsx
export default class TaskList extends React.Component {
  constructor(props) {
    super(props);
    this.handleCheck = this.handleCheck.bind(this);
    // 追加
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick(e) {
    this.props.handledeletetask(e.target.id);
  }


    </label>
    // 追加
    {task.isComplete ? <button
      className="del"
      id={task.id}
      onClick={this.handleClick}
      style={{ float: "right" }}
    >
      Del
    </button> : null}

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

// ./client/src/components/todo-app.jsx
this.handleDeleteTasks = this.handleDeleteTasks.bind(this);
// 追加
this.handleDeleteTask = this.handleDeleteTask.bind(this);
}

handleDeleteTask(id) {
  this.props.dispatch(actions.deleteTask(id));
}


  <TaskList
    tasks={this.props.tasks}
    handlecheck={this.handleTaskCompletionChange}
    // 追加
    handledeletetask={this.handleDeleteTask}
  />

サーバーと通信するメソッドなどを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(tempErrorMessage('API Error'));
        dispatch(toggleLoading(false));
        console.log('/api/tasks/del_tasks', status, err.toString());
      },
    });
  };
}

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

アクションが渡された時の処理をindex.jsxのreducerに追加。

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

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

React版Todoアプリの完成。

React Redux Rails 4 / 5 「完了したTodoをチェック出来るようにする」

はじめに

Riot.js Redux Railsシリーズで作ったTodoアプリのRiot.js部分をReactを使って書いた。

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

環境構築

Todo Appの作成

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

参考

checkbox

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

// ./client/src/components/task-list.jsx
export default class TaskList extends React.Component {
  constructor(props) {
    super(props);
    // 追加
    this.handleCheck = this.handleCheck.bind(this);
  }

  handleCheck(e) {
    this.props.handlecheck(e.target.id, e.target.checked);
  }

  render() {
    return (
      <ul>
        {this.props.tasks.map(task => (
          <li key={task.id}>
            <label className={task.isComplete ? "completed" : null}>
              <input
                type="checkbox"
                id={task.id}
                checked={task.isComplete}
                onChange={this.handleCheck}
              />
              {task.name}
            </label>
          </li>
        ))}
      </ul>
    );
  }
}

todo-app.jsxにマウントされているtask-listコンポーネント

// ./client/src/components/todo-app.jsx
class TodoApp extends React.Component {
  constructor(props) {
    super(props);
    this.handleNewTask = this.handleNewTask.bind(this);
    this.handleInputForm = this.handleInputForm.bind(this);
    // 追加
    this.handleTaskCompletionChange = this.handleTaskCompletionChange.bind(this);
  }

  // 追加
  handleTaskCompletionChange(id, isComplete) {
    this.props.dispatch(actions.toggleComplete(id, isComplete));
  }


    <TaskList
      tasks={this.props.tasks}
      // 追加
      handlecheck={this.handleTaskCompletionChange}
    />

サーバーにチェックの状態を送るメソッドなどを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.jsxのreducerに追加。

// ./client/src/index.jsx
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にisCompleteを追加。

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

同じく、JSONがisCompleteを返すようにshow.json.jbuilderにisCompleteを追加。

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

再びターミナルで

$ foreman start

でサーバーを開始。

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

Warning

タスクを新しく追加し、チェックの状態を変えると以下のような警告がでるが、解消できなかった。(実力不足!!!)
動作には問題なかったので諦めた。

Warning: TaskList is changing an uncontrolled input of type checkbox to be controlled.  
Input elements should not switch from uncontrolled to controlled (or vice versa).  
Decide between using a controlled or uncontrolled input element for the lifetime of the component.  
More info: https://fb.me/react-controlled-components

サーバーエラー時の処理

チェックの状態が変更された時にサーバー側でエラーが起きると、サーバー側とブラウザ側で矛盾が生じる(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/components/error-message.jsx
import React from 'react';

export default class ErrorMessage extends React.Component {
  render() {
    const style = {
      marginTop: "5px",
      paddingLeft: "10px",
      color: "white",
      backgroundColor: "red",
    };

    return (
      <div style={style}>
        {this.props.message}
      </div>
    );
  }
}

todo-app.jsxでerror-messageコンポーネントを読み込みマウントさせる。

// ./client/src/components/todo-app.jsx
import ErrorMessage from './error-message.jsx';

<ErrorMessage message={"Text Message"} />
{this.props.isLoading

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

// ./client/src/action.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メソッドでアクションが送られたときの処理をreducerに追加。

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

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

//  ./client/src/components/todo-app.jsx
// 書き換え
{this.props.isError ? <ErrorMessage
  message={this.props.errorMessage}
/> : null}
{this.props.isLoading


function mapStateToProps(state) {
  const { tasks, isLoading, isText, isError, errorMessage } = state;
  return {
    tasks,
    isLoading,
    isText,
    isError,
    errorMessage,
  };
}

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

// ./client/src/action.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.jsx
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を削除できるようにする。

React Redux Rails 5 / 5に続く