JavaScript
TypeScript
Mithril.js
redux

Mithril + Redux のTodo ListをTypescriptで(3)

More than 1 year has passed since last update.

Mithril + Redux のTodo ListをTypescriptで(3)

前回の続き。
MithrilのTodoMVCをReduxで再現したメモ。

削除ボタンを作成する。

src/actions/todos.ts
import { Action } from 'redux';
import { createAction } from 'redux-actions';

export const ADD = 'ADD_TODO';
export const TOGGLE = 'TOGGLE_TODO';
export const DELETE = 'DELETE_TODO';

export interface IAddTodoAction extends Action {
  type: 'ADD_TODO';
  payload: {
    text: string;
  };
}
export interface IToggleTodoAction extends Action {
  type: 'TOGGLE_TODO';
  payload: {
    id: number;
  };
}

/**
 * actionを発行する関数。
 */
export const addTodo    = createAction(ADD,    (text: string) => ({ text }));
export const toggleTodo = createAction(TOGGLE, (id: number) => ({ id }));
export const deleteTodo = createAction(DELETE, (id: number) => ({ id }));
src/sagas/todos.ts
import { call, put, select, takeEvery } from 'redux-saga/effects';
import { GET_FAILED, GET_REQUEST, GET_SUCCESS,
         PUT_FAILED, PUT_REQUEST, PUT_SUCCESS } from '../actions/storage';
import { get as getTodo, put as putTodo } from '../browser/storage';
import TodoState from '../models/TodoState';

// ワーカー Saga:GET_REQUEST Action によって起動する
export function* addTodoList(action: {type: string, payload: {text}}) {
  const todos = yield select((state: any) => state.todos);
  const { text } = action.payload;
  const todoList = [...todos, new TodoState({ text })];
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

export function* toggleTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((state: any) => state.todos);
  const { id } = action.payload;
  const todoList = todos.map((t) => {
    // actionCreatorに渡したidと一致するtodoのみ処理
    if (t.id !== id) {
      return t;
    }
    // completedだけを反転
    return  new TodoState({ id:t.id, text:t.text, completed: !t.completed });
  });
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

// ワーカー Saga:PUT_REQUEST Action によって起動する
export function* putTodoList(action: {type: string, payload: {todoList: TodoState[]}}) {
  try {
    yield call(putTodo, action.payload.todoList);
    yield put({ type: PUT_SUCCESS, payload:{ todoList: action.payload.todoList } });
  } catch (e) {
    yield put({ type: PUT_FAILED, message: e.message });
  }
}

// ワーカー Saga:GET_REQUEST Action によって起動する
export function* getTodoList(action: {type: string;}) {
  try {
    const todoList: TodoState[] = yield call(getTodo);
    yield put({ type: GET_SUCCESS, payload:{ todoList } });
  } catch (e) {
    yield put({ type: GET_FAILED, message: e.message });
  }
}

export function* deleteTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((state: any) => state.todos);
  const { id } = action.payload;
  const todoList = todos.filter((t) =>  t.id !== id);
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}
src/sagas/index.ts
import { takeEvery } from 'redux-saga/effects';
import { GET_REQUEST, PUT_REQUEST } from '../actions/storage';
import { ADD, TOGGLE, DELETE } from '../actions/todos';
import { ALL_COMPLETED, ALL_INCOMPLETED,
         allCompletedTodoList, allIncompletedTodoList } from '../modules/allCompoeted';
import { addTodoList, getTodoList, putTodoList, toggleTodo, deleteTodo } from './todos';

function* mySaga() {
  yield takeEvery(ADD, addTodoList);
  yield takeEvery(TOGGLE, toggleTodo);
  yield takeEvery(DELETE, deleteTodo);
  yield takeEvery(GET_REQUEST, getTodoList);
  yield takeEvery(PUT_REQUEST, putTodoList);
  yield takeEvery(ALL_COMPLETED, allCompletedTodoList);
  yield takeEvery(ALL_INCOMPLETED, allIncompletedTodoList);
}

export default mySaga;
src/containers/DeleteTodo.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril'; // tslint:disable-line: no-duplicate-imports
import { deleteTodo } from '../actions/todos';
import { connect } from '../mithril-redux';

interface IAttr {
  props: {
    onClick: ()=>void;
  };
}

interface IOwnProps {
  id:number;
}

function mapDispatchToProps(dispatch, {id}: IOwnProps) {
  return {
    onClick() {
      dispatch(deleteTodo(id));
    },
  };
}

class DeleteTodoComponent implements  ClassComponent<IAttr> {
  public view(vnode): Vnode<IAttr, HTMLElement> {
    const { onClick } = vnode.attrs.props;
    return (
      <button  class="destroy" onclick={onClick}></button>
    );
  }
}

export default connect(null, mapDispatchToProps)(DeleteTodoComponent);
src/components/Todo.tsx
import { ClassComponent, Vnode } from 'mithril';
import * as m from 'mithril'; // tslint:disable-line: no-duplicate-imports
import TodoState from '../models/TodoState';
import DeleteTodo from '../containers/DeleteTodo';

interface IAttr extends TodoState {
  onClick: (id: number) => void;
}

export default class Todo implements  ClassComponent<IAttr> {
  /**
   *
   * @param vnode
   */
  public view({ attrs }: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    const { id, text, completed, onClick } = attrs;
    const classes = completed ? 'completed' : '';
    console.log(attrs);
    return (
    <li class={classes}>
      <label>
        <input class="toggle" type="checkbox" onclick={onClick} checked={completed} />
        {text}
      </label>
      <DeleteTodo id={id}>x</DeleteTodo>
    </li>);
  }
}

cssでbuttonの後にxを表示している。

#todo-list li .destroy:after {
    content: '×';
}

この時点のソース

編集機能を追加する

編集用のinputを表示する。

cssを使って編集中のときだけ表示するようにする。

public/style.css
#todo-list {
    margin: 0;
    padding: 0;
    list-style: none;
}
#todo-list li {
    position: relative;
    font-size: 24px;
    border-bottom: 1px solid #ededed;
}

#todo-list li .toggle {
    text-align: center;
    width: 40px;
    height: auto;
    position: absolute;
    top: 0;
    bottom: 0;
    margin: auto 0;
    border: none; /* Mobile Safari */
    -webkit-appearance: none;
    appearance: none;
}
#todo-list li .toggle:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#000000" stroke-width="3"/></svg>');
}
#todo-list li .toggle:checked:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#bddad5" stroke-width="3"/><path fill="#5dc2af" d="M72 25L42 71 27 56l-4 4 20 20 34-52z"/></svg>');
}
#todo-list li.completed label {
  color: #d9d9d9;
  text-decoration: line-through;
}
#todo-list li label{
    white-space: pre-line;
    word-break: break-all;
    padding: 15px 60px 15px 15px;
    margin-left: 45px;
    display: block;
    line-height: 1.2;
    transition: color 0.4s;
}
#todo-list li.editing label {
    display: none;
}
#todo-list li .edit {
    display: none;
}
#todo-list li .destroy {
    display: none;
    position: absolute;
    top: 0;
    right: 10px;
    bottom: 0;
    width: 40px;
    height: 40px;
    margin: auto 0;
    font-size: 30px;
    color: #cc9a9a;
    margin-bottom: 11px;
    transition: color 0.2s ease-out;
}
#todo-list li .destroy:hover {
    color: #af5b5e;
}
#todo-list li .destroy:after {
    content: '×';
}
#todo-list li:hover .destroy {
    display: block;
}
#todo-list li.editing .destroy {
    display: none;
}
#todo-list .editing .edit {
    display: block;
    width: 506px;
    padding: 13px 17px 12px 17px;
    margin: 0 0 0 55px;
}

todoStateのプロパティにeditingを追加。

src/models/todoState.ts
const uniqueId = (() => {
  let count = 0;
  return () => {
    count += 1;
    return count;
  };
})();

export default class TodoState {
  public id: number;
  public text: string;
  public completed: boolean = false;
  public editing: boolean = false;

  constructor(data) {
    this.id = uniqueId();
    this.text = data.text;
    this.completed = data.completed || false;
    this.editing = data.editing || false;
  }
}

編集用のInputと表示用のLabelを持ったコンポーネントを作成。

src/containers/EditTodo.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode } from 'mithril'; // tslint:disable-line: no-duplicate-imports
import { connect } from '../mithril-redux';
import { editingTodo } from '../actions/todos';
import TodoState from '../models/TodoState';
interface IAttr{}

interface IOwnProps {
  id:number;
  text: string;
}

const mapStateToProps = (store, {text}: IOwnProps) => {
  return { text };
};


const mapDispatchToProps = (dispatch, {id}: IOwnProps) => {
  return {
    onDoubleClick() {
      dispatch(editingTodo(id));
    },
  };
}

class EditTodoComponent implements  ClassComponent<IAttr> {
  public view(vnode): Vnode<IAttr, HTMLElement> {
    const { text, onDoubleClick } = vnode.attrs.props;

    return (
      <div>
        <label ondblclick={onDoubleClick}>
          {text}
        </label>
        <input class="edit" value={text} />
      </div>
    );
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(EditTodoComponent);

ラベルの代わりにコンポーネントを使用するように変更。
編集中のクラスを追加。

src/components/Todo.tsx
import { ClassComponent, Vnode } from 'mithril';
import * as m from 'mithril'; // tslint:disable-line: no-duplicate-imports
import TodoState from '../models/TodoState';
import DeleteTodo from '../containers/DeleteTodo';
import EditTodo from '../containers/EditTodo';

interface IAttr extends TodoState {
  onClick: (id: number) => void;
}

export default class Todo implements  ClassComponent<IAttr> {
  /**
   *
   * @param vnode
   */
  public view({ attrs }: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    const { id, text, completed, editing, onClick } = attrs;
    const classes = ( completed ? 'completed ' : '') + 
                    ( editing   ? 'editing '   : '');
    return (
    <li class={classes}>
      <div class="view">
        <input class="toggle" type="checkbox" onclick={onClick} checked={completed} />
        <EditTodo text={text} id={id} />
        <DeleteTodo id={id} />
      </div>
    </li>);
  }
}

アクションを追加。

src/actions/todos.ts
import { Action } from 'redux';
import { createAction } from 'redux-actions';

export const ADD = 'ADD_TODO';
export const TOGGLE = 'TOGGLE_TODO';
export const DELETE = 'DELETE_TODO';
export const EDITING = 'EDITING_TODO';

export interface IAddTodoAction extends Action {
  type: 'ADD_TODO';
  payload: {
    text: string;
  };
}
export interface IToggleTodoAction extends Action {
  type: 'TOGGLE_TODO';
  payload: {
    id: number;
  };
}
export interface IEditingAction extends Action {
  type: 'EDITING_TODO';
  payload: {
    id: number;
  };
}
/**
 * actionを発行する関数。
 */
export const addTodo    = createAction(ADD,    (text: string) => ({ text }));
export const toggleTodo = createAction(TOGGLE, (id: number) => ({ id }));
export const deleteTodo = createAction(DELETE, (id: number) => ({ id }));
export const editingTodo = createAction(EDITING, (id: number) => ({ id }));

editingTodoを追加。

src/sagas/todos.ts
import { call, put, select, takeEvery } from 'redux-saga/effects';
import { GET_FAILED, GET_REQUEST, GET_SUCCESS,
         PUT_FAILED, PUT_REQUEST, PUT_SUCCESS } from '../actions/storage';
import { get as getTodo, put as putTodo } from '../browser/storage';
import TodoState from '../models/TodoState';

export function* addTodoList(action: {type: string, payload: {text}}) {
  const todos = yield select((state: any) => state.todos);
  const { text } = action.payload;
  const todoList = [...todos, new TodoState({ text })];
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

export function* toggleTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((state: any) => state.todos);
  const { id } = action.payload;
  const todoList = todos.map((t) => {
    if (t.id !== id) {
      return t;
    }
    t.completed = !t.completed;
    return  new TodoState(t);
  });
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}
export function* putTodoList(action: {type: string, payload: {todoList: TodoState[]}}) {
  try {
    yield call(putTodo, action.payload.todoList);
    yield put({ type: PUT_SUCCESS, payload:{ todoList: action.payload.todoList } });
  } catch (e) {
    yield put({ type: PUT_FAILED, message: e.message });
  }
}
export function* getTodoList(action: {type: string;}) {
  try {
    const todoList: TodoState[] = yield call(getTodo);
    yield put({ type: GET_SUCCESS, payload:{ todoList } });
  } catch (e) {
    yield put({ type: GET_FAILED, message: e.message });
  }
}
export function* deleteTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((state: any) => state.todos);
  const { id } = action.payload;
  const todoList = todos.filter((t) =>  t.id !== id);
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}
export function* editingTodo(action: {type: string, payload: {id}}) {
  console.log(action);
  const todos = yield select((store: any) => store.todos);
  const { id } = action.payload;
  const todoList = todos.map((t) => {
    // actionCreatorに渡したidと一致するtodoのみ処理
    if (t.id !== id) {
      return t;
    }
    // editingをtrueに
    t.editing = true;
    return  new TodoState(t);
  });
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}
src/sagas/index.ts
import { takeEvery } from 'redux-saga/effects';
import { GET_REQUEST, PUT_REQUEST } from '../actions/storage';
import { ADD, TOGGLE, DELETE, EDITING } from '../actions/todos';
import { ALL_COMPLETED, ALL_INCOMPLETED,
         allCompletedTodoList, allIncompletedTodoList } from '../modules/allCompoeted';
import { addTodoList, getTodoList, putTodoList, toggleTodo, deleteTodo, editingTodo } from './todos';
function* mySaga() {
  yield takeEvery(ADD, addTodoList);
  yield takeEvery(TOGGLE, toggleTodo);
  yield takeEvery(DELETE, deleteTodo);
  yield takeEvery(GET_REQUEST, getTodoList);
  yield takeEvery(PUT_REQUEST, putTodoList);
  yield takeEvery(ALL_COMPLETED, allCompletedTodoList);
  yield takeEvery(ALL_INCOMPLETED, allIncompletedTodoList);
  yield takeEvery(EDITING, editingTodo);
}
export default mySaga;

この時点のソース

編集を確定させる。

doneEditingのアクションを追加

src/actions/todos.ts
import { Action } from 'redux';
import { createAction } from 'redux-actions';

export const ADD = 'ADD_TODO';
export const TOGGLE = 'TOGGLE_TODO';
export const DELETE = 'DELETE_TODO';
export const EDITING = 'EDITING_TODO';
export const DONE_EDITING = 'DONE_EDITING_TODO';

export interface IAddTodoAction extends Action {
  type: 'ADD_TODO';
  payload: {
    text: string;
  };
}
export interface IToggleTodoAction extends Action {
  type: 'TOGGLE_TODO';
  payload: {
    id: number;
  };
}
export interface IEditingAction extends Action {
  type: 'EDITING_TODO';
  payload: {
    id: number;
  };
}
export interface IDoneEditingAction extends Action {
  type: 'EDITING_TODO';
  payload: {
    id: number;
    text: string;
  };
}
/**
 * actionを発行する関数。
 */
export const addTodo    = createAction(ADD,    (text: string) => ({ text }));
export const toggleTodo = createAction(TOGGLE, (id: number) => ({ id }));
export const deleteTodo = createAction(DELETE, (id: number) => ({ id }));
export const editingTodo = createAction(EDITING, (id: number) => ({ id }));
export const doneEditingTodo = createAction(DONE_EDITING, (id:number, text: string) =>({id, text}));

doneEditingのsagaを追加

src/sagas/todos.ts
import { call, put, select, takeEvery } from 'redux-saga/effects';
import { GET_FAILED, GET_REQUEST, GET_SUCCESS,
         PUT_FAILED, PUT_REQUEST, PUT_SUCCESS } from '../actions/storage';
import { get as getTodo, put as putTodo } from '../browser/storage';
import TodoState from '../models/TodoState';

export function* addTodoList(action: {type: string, payload: {text}}) {
  const todos = yield select((state: any) => state.todos);
  const { text } = action.payload;
  const todoList = [...todos, new TodoState({ text })];
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

export function* toggleTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((state: any) => state.todos);
  const { id } = action.payload;
  const todoList = todos.map((t) => {
    if (t.id !== id) {
      return t;
    }
    t.completed = !t.completed;
    return  new TodoState(t);
  });
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

export function* putTodoList(action: {type: string, payload: {todoList: TodoState[]}}) {
  try {
    yield call(putTodo, action.payload.todoList);
    yield put({ type: PUT_SUCCESS, payload:{ todoList: action.payload.todoList } });
  } catch (e) {
    yield put({ type: PUT_FAILED, message: e.message });
  }
}

export function* getTodoList(action: {type: string;}) {
  try {
    const todoList: TodoState[] = yield call(getTodo);
    yield put({ type: GET_SUCCESS, payload:{ todoList } });
  } catch (e) {
    yield put({ type: GET_FAILED, message: e.message });
  }
}

export function* deleteTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((state: any) => state.todos);
  const { id } = action.payload;
  const todoList = todos.filter((t) =>  t.id !== id);
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

export function* editingTodo(action: {type: string, payload: {id}}) {
  const todos = yield select((store: any) => store.todos);
  const { id } = action.payload;
  const todoList = todos.map((t) => {
    if (t.id !== id) {
      return t;
    }
    t.editing = true;
    return  new TodoState(t);
  });
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}

export function* doneEditingTodo(action: {type: string, payload: {id, text}}) {
  const todos = yield select((store: any) => store.todos);
  const { id, text } = action.payload;
  const todoList = todos.map((t) => {
    if (t.id !== id) {
      return t;
    }

    t.editing = false;
    t.text = text;
    return  new TodoState(t);
  });
  yield put({ type: PUT_REQUEST, payload:{ todoList } });
}
src/sagas/index.ts
import { takeEvery } from 'redux-saga/effects';
import { GET_REQUEST, PUT_REQUEST } from '../actions/storage';
import { ADD, TOGGLE, DELETE, EDITING, DONE_EDITING } from '../actions/todos';
import { ALL_COMPLETED, ALL_INCOMPLETED,
         allCompletedTodoList, allIncompletedTodoList } from '../modules/allCompoeted';
import { addTodoList, getTodoList, putTodoList, toggleTodo, deleteTodo, editingTodo, doneEditingTodo } from './todos';
function* mySaga() {
  yield takeEvery(ADD, addTodoList);
  yield takeEvery(TOGGLE, toggleTodo);
  yield takeEvery(DELETE, deleteTodo);
  yield takeEvery(GET_REQUEST, getTodoList);
  yield takeEvery(PUT_REQUEST, putTodoList);
  yield takeEvery(ALL_COMPLETED, allCompletedTodoList);
  yield takeEvery(ALL_INCOMPLETED, allIncompletedTodoList);
  yield takeEvery(EDITING, editingTodo);
  yield takeEvery(DONE_EDITING, doneEditingTodo);
}
export default mySaga;

componentに編集確定イベントとキャンセルイベントの追加

src/containers/EditTodo.tsx
import * as m from 'mithril';
import { ClassComponent, Vnode, VnodeDOM } from 'mithril'; // tslint:disable-line: no-duplicate-imports
import { connect } from '../mithril-redux';
import { editingTodo, doneEditingTodo } from '../actions/todos';
import TodoState from '../models/TodoState';
interface IAttr{}
interface IOwnProps {
  id:number;
  text: string;
  editing: boolean;
}

const mapStateToProps = (store, { text, editing}: IOwnProps) => {
  return { text, editing };
};
const mapDispatchToProps = (dispatch, {id}: IOwnProps) => {
  return {
    onDoubleClick() {
      dispatch(editingTodo(id));
    },
    onBlur(text:string){
      dispatch(doneEditingTodo(id, text));
    }
  };
}

class EditTodoComponent implements  ClassComponent<IAttr> {
  private value: string;

  public view(vnode): Vnode<IAttr, HTMLElement> {
    const { onDoubleClick, onBlur, text, editing } = vnode.attrs.props;
    this.value = text;
    const doneEditing = () => {
      const val = this.value;
      this.value = '';
      onBlur(val);
    };

    return (
      <div>
        <label ondblclick={onDoubleClick}>
          {text}
        </label>
        <input 
          class="edit" 
          value={this.value}
          onupdate={
            (vnode: VnodeDOM<{}, this>)=>{
              if(editing){
                const element = vnode.dom as HTMLElement;
                element.focus();
              }
            }
          }
          oninput={m.withAttr('value', value => this.value = value)}
          onblur={doneEditing}
          onkeyup={
            (e:KeyboardEvent)=>{
              if(e.key === 'Enter'){
                doneEditing();
              }
              else if(e.key === "Escape"){
                this.value = text;
                onBlur(text);
              }
            }
          }
          />
      </div>
    );
  }
}
export default connect(mapStateToProps, mapDispatchToProps)(EditTodoComponent);

editingの追加。

src/components/Todo.tsx
import { ClassComponent, Vnode } from 'mithril';
import * as m from 'mithril'; // tslint:disable-line: no-duplicate-imports
import TodoState from '../models/TodoState';
import DeleteTodo from '../containers/DeleteTodo';
import EditTodo from '../containers/EditTodo';

interface IAttr extends TodoState {
  onClick: (id: number) => void;
}

export default class Todo implements  ClassComponent<IAttr> {
  public view({ attrs }: Vnode<IAttr, this>): Vnode<IAttr, HTMLElement> {
    const { id, text, completed, editing, onClick } = attrs;
    const classes = ( completed ? 'completed ' : '') + 
                    ( editing   ? 'editing '   : '');
    return (
    <li class={classes}>
      <div class="view">
        <input class="toggle" type="checkbox" onclick={onClick} checked={completed} />
        <EditTodo text={text} id={id} editing={editing} />
        <DeleteTodo id={id} />
      </div>
    </li>);
  }
}

この時点のソース

参考

todomvc
todomvc-common
todomvc-app-css
shadow-dom
key event