ReduxでTrello風UIを実装する(試行錯誤編)

さて、トレロUIを実装してみる。2016/4/6 19:48スタート。何時間でできるかな。
 
まずはCSSというか見た目の構造をざっくりと考える。
 
リストを、左から追加していくようにする。リストにはタイトルがあり、編集可能。
リストの中にカードがあって、上から追加していく。カードはもちろん編集可能で、リスト間をドラッグ・ドロップで移動できる。
 
まずはリストのCSS。縦の長さは画面の100%ということでいいだろう。もしはみ出した場合はその中でスクロールする必要がある。
横幅は決めておこう。

次に、カード。横幅は100%だけど、高さは文字数次第でアジャストされる必要がある。角を丸めて適当に色をつけておこう。

リストの下にはカードを追加するためのボタンが必要。

以上を実際にブラウザで実験しつつ、Trelloを参考にしつつ考えたら次のような感じになった。
 

body {
  background: #0079bf;
  color: #4d4d4d;
  font: 14px "Helvetica Neue",Arial,Helvetica,sans-serif;
  line-height: 18px;
}
.list {
  float: left;
  width: 250px;
  height: 100%;
  padding: 0 10px;
  overflow-y: scroll;
  background: #e2e4e6;
  border-radius: 3px;
}
.list > .list-title {
  font-size: 15px;
  line-height: 18px;
  margin: 0;
  padding: 10px 0;
  min-height: 19px;
  min-width: 30px;
  overflow: hidden;
  text-overflow: ellipsis;
  word-wrap: break-word;
}
.list > .add-card {
  margin: 10px 0;
}
.list > .add-card > a {
  text-decoration: none;
  color: #8c8c8c;
}
.card {
  min-height: 40px;
  background: #fff;
  padding: 5px;
  border-radius: 3px;
}

 
Screen Shot 2016-04-06 at 20.16.03

おお、だいぶリアルだ…こんなに似せていいんだろうか。

さて、JavaScriptをどう書いていくかもざっくりと考えておこう。

必要となるnpmパッケージはreact reduxで使う一色の他に、ドラッグアンドドロップのためにreact-dndを使う。

ソフトウェアの機能をアクションとデータ構造の点から整理すると次のようになるだろう。
 
アクション

addList() — カラムを追加する
editList(listID) — リストのタイトルを編集開始
saveList(listID, newTitle) — リストの変更を保存
cancelEditList(listID) — リストの変更をキャンセル
destroyList(listID) — リストを削除
addCard(listID) — カードを追加する
editCard(cardID) — カードの編集開始
saveCard(cardID, newText) — カードの変更保存
cancelEditCard(cardID) — カードの保存をキャンセル
destroyCard(cardID) — カードを削除
moveCard(cardID, listID) -- カードをlistIDに移動

 
データ構造(ストア)

listOfList = [
  {
    id: 1,
    title: ‘list 1 title’,
    isEditing: 
    cards: [
      {
        id: 1,
        title: ‘card title’,
        isEditing: 
      }
    ]
  }
]

ストア層は全体としては配列になり、その中にリスト、リストの中にカードが入っているという構造。
それぞれのリスト・カードはIDを持っていて、作るたびにインクリメントされる。

これらの構造をreducerを通して定義していくことになるので、reducerも考えよう。

list, cardが主なデータモデルなので、それぞれについて2つずつ(リスト用と、各アイテム用)のreducer関数を用意する。

reducers/list.js

let nextListID = 1;
const listItem = (state, action) => {
  switch (action.type) {
    case 'ADD_LIST':
      return {
        id: nextListID++,
        title: action.title,
        isEditing: false
      };
    case 'EDIT_LIST':
      if (state.id !== action.id) {
        return;
      }
      return Object.assign({}, state, { isEditing: true });      
    case 'SAVE_LIST':
      if (state.id !== action.id) {
        return;
      }
      return Object.assign({}, state, { title: action.title });      
    case 'CANCEL_EDIT_LIST':
      if (state.id !== action.id) {
        return;
      }
      return Object.assign({}, state, { isEditing: false });
    default:
      return state;
  }
}
const lists = (state = [], action) => {
  switch (action.type) {
    case 'ADD_LIST':
      return [...state, listItem({}, action)];
    case 'EDIT_LIST':
      return state.map(item => listItem(item, action));
    case 'SAVE_LIST':
      return state.map(item => listItem(item, action));
    case 'CANCEL_EDIT_LIST':
      return state.map(item => listItem(item, action));      
    default:
      return state;
  }
}
export default lists;

reducers/card.js

let nextCardID = 1;
const card = (state, action) => {
  switch (action.type) {
    case 'ADD_CARD':
      return {
        id: nextCardID++,
        text: action.text,
        isEditing: false
      };
    case 'EDIT_CARD':
      if (state.id !== action.id) {
        return;
      }
      return Object.assign({}, state, { isEditing: true });      
    case 'SAVE_CARD':
      if (state.id !== action.id) {
        return;
      }
      return Object.assign({}, state, { text: action.text });      
    case 'CANCEL_EDIT_CARD':
      if (state.id !== action.id) {
        return;
      }
      return Object.assign({}, state, { isEditing: false });
    default:
      return state;
  }
}
const cards = (state = [], action) => {
  switch (action.type) {
    case 'ADD_CARD':
      return [...state, listItem({}, action)];
    case 'EDIT_CARD':
      return state.map(item => card(item, action));
    case 'SAVE_CARD':
      return state.map(item => card(item, action));
    case 'CANCEL_EDIT_CARD':
      return state.map(item => card(item, action));      
    default:
      return state;
  }
}
export default cards;

こうしてみると結構ワンパターンだし簡単だな。
さて、いよいよ実際の作業に突入する。まずはプロジェクト作成から。

mkdir redux-trello-ui; cd redux-trello-ui
npm init
npm install --save react react-dom redux react-redux react-dnd react-dnd-html5-backend
npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react webpack

webpackとかの設定は省略。

まずはアプリのエントリーポイントから。Providerにreducerを読み込んだstoreを渡して、トップのコンポーネントを包むことで、reduxとreactをつなぐことができる。

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { todos } from './reducers';

import Root from './containers/Root';

let store = createStore(todos);

ReactDOM.render(
  
    
  , document.getElementById('react-app')
);

先ほど用意したreducerをまとめる。

import { combineReducers } from 'redux';
import list from './list';
import card from './card';

export default combineReducers({
  list, card
});

っと、、、ここまで書いたのだが、想定しきれてないことが多くてスマートにまとまらなかったので、今日はここまでにしておこう。