LoginSignup
9
7

More than 5 years have passed since last update.

ReactでTrelloみたいなToDoリスト<4> immutability-helper

Last updated at Posted at 2017-07-07

関連記事

外部APIとの通信、Props Callback function

今までは外観を作っただけで、カードの操作はできなかった。
カードの操作ができるように機能を追加してみよう。
また著者のサイトから学習用のAPIサーバーを提供しているので、そのサーバーと通信する形で改善する。
カードを操作する機能については、親コンポーネントからfunctionを作り、Props Callbackとして子コンポーネントへ渡す。

src/index.js
diff --git a/src/index.js b/src/index.js
index dd395b8..6e38759 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
-import KanbanBoard from './components/KanbanBoard';
+import KanbanBoardContainer from './components/KanbanBoardContainer';
 import registerServiceWorker from './registerServiceWorker';
 import './index.css';

@@ -39,5 +39,5 @@ const cardsList = [
   },
 ];

-ReactDOM.render(<KanbanBoard cards={cardsList} />, document.getElementById('root'));
+ReactDOM.render(<KanbanBoardContainer cards={cardsList} />, document.getElementById('root'));
 registerServiceWorker();

外部APIとの通信でカードリストを取ってくるのでcardsListという仮のデータは要らないが、そのまま置いて進む。
もちろん消しても構わない。

ここからが本番だ。
カードを操作するためにReactから提供しているimmutability-helperを使う。
本でreact-addons-updateを紹介しているが、”legacyだから代わりにimmutability-helperを使ってくれ”と公式ドキュメントに書かれていた。
immutability-helperを設置してみよう。

yarn add immutability-helper

使い方はこんな感じ

import update from 'immutability-helper';

const state1 = ['x'];
const state2 = update(state1, {$push: ['y']}); // ['x', 'y']

state1とstate2は別のオブジェクトだ。
updateは対象objectのコピーを作ってそのコピーを更新する。
state2はstate1をコピーして作っているが、別のobjectなので、state2に変更があってもstate1には影響を及ぼさない。
immutability-helperの有効なコマンドを整理した。
githubにあるサンプルコードをそのまま持ってきた。
見ればわかると思うが、arrayとほぼ同じだ。
すこしややこしいところは補足したので、参考にしよう。

  • {$push: array}
const initialArray = [1, 2, 3];
const newArray = update(initialArray, {$push: [4]}); // => [1, 2, 3, 4]
  • {$unshift: array}
const initialArray = [1, 2, 3];
const newArray = update(initialArray, {$unshift: [0]}); // => [0, 1, 2, 3]
  • {$splice: array of arrays}
const collection = [1, 2, {a: [12, 17, 15]}];
const newCollection = update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
// => [1, 2, {a: [12, 13, 14, 15]}]

:bulb: 補足
collectionのindexが2、keyがaでitemのスタートindexが1、そこから1個を消して、13と14を追加する。

  • {\$set: any}、{\$apply: function}
const obj = {a: 5, b: 3};
const newObj = update(obj, {b: {$apply: function(x) {return x * 2;}}});
// => {a: 5, b: 6}
// This is equivalent, but gets verbose for deeply nested collections:
const newObj2 = update(obj, {b: {$set: obj.b * 2}});

:bulb: 補足
結果としてnewObjとnewObj2は同じだ。値としてapplyは関数を、setは式になっている。

  • {$merge: object}
const obj = {a: 5, b: 3};
const newObj = update(obj, {$merge: {b: 6, c: 7}}); // => {a: 5, b: 6, c: 7}
src/components/KanbanBoardContainer.js
diff --git a/src/components/KanbanBoardContainer.js b/src/components/KanbanBoardContainer.js
new file mode 100644
index 0000000..3197efd
--- /dev/null
+++ b/src/components/KanbanBoardContainer.js
@@ -0,0 +1,142 @@
+import React, { Component } from 'react';
+import update from 'immutability-helper';
+import 'whatwg-fetch';
+import KanbanBoard from './KanbanBoard';
+
+const API_URL = 'http://kanbanapi.pro-react.com';
+const API_HEADERS = {
+  'Content-Type': 'application/json',
+  Authorization: 'CHANGE THIS VALUE',
+};
+
+class KanbanBoardContainer extends Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      cards: [],
+    };
+    this.addTask = this.addTask.bind(this);
+    this.deleteTask = this.deleteTask.bind(this);
+    this.toggleTask = this.toggleTask.bind(this);
+  }
+
+  componentDidMount() {
+    fetch(`${API_URL}/cards`, { headers: API_HEADERS })
+      .then(response => response.json())
+      .then(responseData => this.setState({ cards: responseData }))
+      .catch(error => console.error('Error fetching and parsing data', error));
+  }
+
+  addTask(cardId, taskName) {
+    const prevState = this.state;
+    const cardIndex = this.state.cards.findIndex(card => card.id === cardId);
+    const newTask = { id: Date.now(), name: taskName, done: false };
+    const nextState = update(this.state.cards, {
+      [cardIndex]: {
+        tasks: { $push: [newTask] },
+      },
+    });
+
+    this.setState({ cards: nextState });
+
+    fetch(`${API_URL}/cards/${cardId}/tasks`, {
+      method: 'post',
+      headers: API_HEADERS,
+      body: JSON.stringify(newTask),
+    })
+      .then((response) => {
+        if (response.ok) {
+          return response.json();
+        }
+        throw new Error("Server response wasn't OK");
+      })
+      .then((responseData) => {
+        newTask.id = responseData.id;
+        this.setState({ cards: nextState });
+      })
+      .catch(() => {
+        this.setState(prevState);
+      });
+  }
+
+  deleteTask(cardId, taskId, taskIndex) {
+    const cardIndex = this.state.cards.findIndex(card => card.id === cardId);
+    const prevState = this.state;
+    const nextState = update(this.state.cards, {
+      [cardIndex]: {
+        tasks: { $splice: [[taskIndex, 1]] },
+      },
+    });
+
+    this.setState({ cards: nextState });
+
+    fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
+      method: 'delete',
+      headers: API_HEADERS,
+    })
+      .then((response) => {
+        if (!response.ok) {
+          throw new Error("Server response wasn't OK");
+        }
+      })
+      .catch((error) => {
+        console.error('Fetch error:', error);
+        this.setState(prevState);
+      });
+  }
+
+  toggleTask(cardId, taskId, taskIndex) {
+    const prevState = this.state;
+    const cardIndex = this.state.cards.findIndex(card => card.id === cardId);
+    let newDoneValue;
+    const nextState = update(
+      this.state.cards,
+      {
+        [cardIndex]: {
+          tasks: {
+            [taskIndex]: {
+              done: {
+                $apply: (done) => {
+                  newDoneValue = !done;
+                  return newDoneValue;
+                },
+              },
+            },
+          },
+        },
+      },
+    );
+
+    this.setState({ cards: nextState });
+
+    fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
+      method: 'put',
+      headers: API_HEADERS,
+      body: JSON.stringify({ done: newDoneValue }),
+    })
+      .then((response) => {
+        if (!response.ok) {
+          throw new Error('Server response wasn\'t OK');
+        }
+      })
+      .catch((error) => {
+        console.error('Fetch error:', error);
+        this.setState(prevState);
+      });
+  }
+
+  render() {
+    return (
+      <KanbanBoard
+        cards={this.state.cards}
+        taskCallbacks={{
+          toggle: this.toggleTask,
+          delete: this.deleteTask,
+          add: this.addTask,
+        }}
+      />
+    );
+  }
+}
+
+export default KanbanBoardContainer;

ソースコードが長く見えるが、外部APIと通信しながら、タスクを追加、削除、トグルするため関連関数を追加しただけだ。
componentDidMountはコンポーネントがマウント(レンダーリング)された直後に必ず1回呼び出される関数だ。
Promiseオブジェクトについては割愛する。
APIサーバーとのやりとりを行う際は、fetch~then~catch順でするものだと覚えておく。
使ううちにわかるようになる。

react/forbid-prop-types

PropTypesにてany、array、objectはeslintで使用が禁止されている。
確かに、どんなタイプなのか明らかにしないとPropTypesを使う意味がない。
arrayの代わりにarrayOfを、objectの代わりにshapeを使おう。
詳細は、PropTypesのValidatorで確認しよう。

src/components/KanbanBoard.js
diff --git a/src/components/KanbanBoard.js b/src/components/KanbanBoard.js
index b89409d..8ee231e 100644
--- a/src/components/KanbanBoard.js
+++ b/src/components/KanbanBoard.js
@@ -9,6 +9,7 @@ class KanbanBoard extends Component {
         <List
           id="todo"
           title="To Do"
+          taskCallbacks={this.props.taskCallbacks}
           cards={
             this.props.cards.filter(card => card.status === 'todo')
           }
@@ -16,6 +17,7 @@ class KanbanBoard extends Component {
         <List
           id="in-progress"
           title="In Progress"
+          taskCallbacks={this.props.taskCallbacks}
           cards={
             this.props.cards.filter(card => card.status === 'in-progress')
           }
@@ -23,6 +25,7 @@ class KanbanBoard extends Component {
         <List
           id="done"
           title="Done"
+          taskCallbacks={this.props.taskCallbacks}
           cards={
             this.props.cards.filter(card => card.status === 'done')
           }
@@ -34,6 +37,11 @@ class KanbanBoard extends Component {

 KanbanBoard.propTypes = {
   cards: PropTypes.arrayOf(PropTypes.object).isRequired,
+  taskCallbacks: PropTypes.shape({
+    toggle: PropTypes.func,
+    delete: PropTypes.func,
+    add: PropTypes.func,
+  }).isRequired,
 };

 export default KanbanBoard;
src/components/List.js
diff --git a/src/components/List.js b/src/components/List.js
index c548d80..2ba25ee 100644
--- a/src/components/List.js
+++ b/src/components/List.js
@@ -7,6 +7,7 @@ class List extends Component {
     const cards = this.props.cards.map(card =>
       (<Card
         key={card.id}
+        taskCallbacks={this.props.taskCallbacks}
         id={card.id}
         title={card.title}
         description={card.description}
@@ -27,6 +28,11 @@ class List extends Component {
 List.propTypes = {
   title: PropTypes.string.isRequired,
   cards: PropTypes.arrayOf(PropTypes.object).isRequired,
+  taskCallbacks: PropTypes.shape({
+    toggle: PropTypes.func,
+    delete: PropTypes.func,
+    add: PropTypes.func,
+  }).isRequired,
 };

 export default List;
src/components/Card.js
diff --git a/src/components/Card.js b/src/components/Card.js
index 5025f5f..19e0f4c 100644
--- a/src/components/Card.js
+++ b/src/components/Card.js
@@ -22,7 +22,11 @@ class Card extends Component {
       cardDetails = (
         <div className="card__details">
           <span dangerouslySetInnerHTML={{ __html: Marked(this.props.description) }} />
-          <CheckList cardId={this.props.id} tasks={this.props.tasks} />
+          <CheckList
+            cardId={this.props.id}
+            tasks={this.props.tasks}
+            taskCallbacks={this.props.taskCallbacks}
+          />
         </div>
       );
     }
@@ -81,6 +85,11 @@ Card.propTypes = {
   description: PropTypes.string.isRequired,
   color: PropTypes.string.isRequired,
   tasks: PropTypes.arrayOf(PropTypes.object).isRequired,
+  taskCallbacks: PropTypes.shape({
+    toggle: PropTypes.func,
+    delete: PropTypes.func,
+    add: PropTypes.func,
+  }).isRequired,
 };

 export default Card;
src/components/CheckList.js
diff --git a/src/components/CheckList.js b/src/components/CheckList.js
index 4de883f..bd1b701 100644
--- a/src/components/CheckList.js
+++ b/src/components/CheckList.js
@@ -2,12 +2,37 @@ import React, { Component } from 'react';
 import PropTypes from 'prop-types';

 class CheckList extends Component {
+  constructor(props) {
+    super(props);
+    this.checkInputKeyPress = this.checkInputKeyPress.bind(this);
+  }
+
+  checkInputKeyPress(evt) {
+    const event = evt;
+    if (event.key === 'Enter') {
+      this.props.taskCallbacks.add(this.props.cardId, event.target.value);
+      event.target.value = '';
+    }
+  }
+
   render() {
-    const tasks = this.props.tasks.map(task => (
+    const tasks = this.props.tasks.map((task, taskIndex) => (
       <li key={task.id} className="checklist__task">
-        <input type="checkbox" defaultChecked={task.done} />
+        <input
+          type="checkbox"
+          checked={task.done}
+          onChange={
+            this.props.taskCallbacks.toggle.bind(null, this.props.cardId, task.id, taskIndex)
+          }
+        />
         {task.name}{' '}
-        <span className="checklist__task--remove" />
+        <span
+          className="checklist__task--remove"
+          role="presentation"
+          onClick={
+            this.props.taskCallbacks.delete.bind(null, this.props.cardId, task.id, taskIndex)
+          }
+        />
       </li>
     ));

@@ -18,6 +43,7 @@ class CheckList extends Component {
           type="text"
           className="checklist--add-task"
           placeholder="Type then hit Enter to add a task"
+          onKeyPress={this.checkInputKeyPress}
         />
       </div>
     );
@@ -25,7 +51,13 @@ class CheckList extends Component {
 }

 CheckList.propTypes = {
+  cardId: PropTypes.number.isRequired,
   tasks: PropTypes.arrayOf(PropTypes.object).isRequired,
+  taskCallbacks: PropTypes.shape({
+    toggle: PropTypes.func,
+    delete: PropTypes.func,
+    add: PropTypes.func,
+  }).isRequired,
 };

 export default CheckList;

eslintから”jsx内でbindを使うな!”と怒られる。
今までconstructorでbindすることで回避していたが、ここではarrow functionを使ってみよう。
arrow functionでのthisはarrow functionが宣言された場所を指しているため、bindが要らない。

src/components/CheckList.js
diff --git a/src/components/CheckList.js b/src/components/CheckList.js
index bd1b701..d554084 100644
--- a/src/components/CheckList.js
+++ b/src/components/CheckList.js
@@ -22,7 +22,7 @@ class CheckList extends Component {
           type="checkbox"
           checked={task.done}
           onChange={
-            this.props.taskCallbacks.toggle.bind(null, this.props.cardId, task.id, taskIndex)
+            () => this.props.taskCallbacks.toggle(this.props.cardId, task.id, taskIndex)
           }
         />
         {task.name}{' '}
@@ -30,7 +30,7 @@ class CheckList extends Component {
           className="checklist__task--remove"
           role="presentation"
           onClick={
-            this.props.taskCallbacks.delete.bind(null, this.props.cardId, task.id, taskIndex)
+            () => this.props.taskCallbacks.delete(this.props.cardId, task.id, taskIndex)
           }
         />
       </li>

これで、eslintからのエラーは全部クリアした。
著者が学習用で用意したサーバーからデータを取ってくるので、すでに他の人々が入れたデータが表示される。
カードの中のタスクを追加、削除、トグルしてみよう。

9
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
7