LoginSignup
1
0

More than 5 years have passed since last update.

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

Posted at

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

1
0
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
1
0