見出し画像

Nuxt × TypeScript でTodoListとユーザ認証を実装してFirebase Hostingにデプロイ [Tutorial - Part 2/5 - TodoListを実装]

概要


Nuxt×TypeScriptでTodoListを実装し、Firebase Hostingにデプロイするチュートリアルです。簡単なCRUD(Create, Read, Update, Delete)アプリを作成することで、NuxtとTypeScriptの概要を掴んでもらうことが目的です。


Part1では「環境構築とHelloWorldの表示」までを行いました。このPart2では、TodoListを実装していきます。この Part が最重要です!!

このチュートリアルの全容は下記です。


Part1: 環境構築とHelloWorldの表示

Part2: TodoListを実装する

Part3:Bulmaを使ってデザインを整える

Part4:Firebase Hostingにデプロイする

Part5:Firebase Authを用いてユーザ認証機能を追加する


Part2の完成物であるソースと動画を先に貼っておきます。




では早速、Part2の内容に入っていきます。Nuxtの実装方法を色々と調べたのですが、色々な方法があるようで。。結局、公式に倣っておけば間違いないだろうと思い、下記のExampleを参考に実装していくことにしました。

状態の管理に Vuex を用います。下記の図をなんとなく雰囲気で理解すれば大丈夫です。

・Vue Components -> 表示の部分。ここからActionを呼ぶ。

・Action -> Mutationを呼び出す。別にバックエンドAPIを使わない場合でもActionを発火させてMutationを使う感じになると思います。

・Mutation -> Stateに変更を加える

・State -> 状態。ここの場合はTodoの状態を指します。

また、Stateの管理は Store というもので行い、単純にStateの中身を呼び出す場合はStoreに定義したGetterを使います。


色々書きましたが、たぶんコード見た方が分かりやすいです。

あと、Nuxtの概要くらいは知っておいても良さそうです。


1. gitのブランチを切る

Part1ではmasterブランチに直接pushしていましたが、実際の開発では、masterブランチは常に正確に動くものを置いておく必要があります。大まかな目的毎にgitのブランチを切り、masterブランチに対するプルリクエストを投げて、レビューを受け、マージする、という過程を辿ります。

~/workspace/nuxt/nuxt-ts-app-tkugimot(master ✔)
⚾️  Toshi
😪 💤 $ git checkout -b feature/todo
Switched to a new branch 'feature/todo'
~/workspace/nuxt/nuxt-ts-app-tkugimot(feature/todo ✔)
⚾️  Toshi
😪 💤 $ 

余談ですが、Terminal を iTerm2 にしたり、シェルを bash -> zsh にしたりすると色々と便利です。


2. root.ts, index.ts を store 直下に準備する

storeの下にrootの設定を用意します。

# store/root.ts
export interface State {}

export const state = (): State => ({})
# store/index.ts
import Vuex from 'vuex';
import * as root from './root';
import * as todos from './modules/todos';

export type RootState = root.State;

const createStore = () => {
  return new Vuex.Store({
    state: root.state(),
    modules: {
    }
  })
}

export default createStore;


3. Todoの module と Component を準備する

拡張性を考え易いコードになるように、Todo関連のコードは module というもので作成していきます。まず store 直下に modules というディレクトリを作成します。

$ cd store
$ mkdir modules

次に、todos の下に todoTypes.ts と todos.ts を作成します。

# store/modules/todoTypes.ts
export interface Todo {
    id: number
    content: string
}
  
export interface State {
    todos: Todo[]
}
# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'

export const name = 'todos';
export const namespaced = true;

export const state = (): State => ({
  todos: []
});

更に、TodoList.vue という名前で TodoList を表示するコンポーネントを作成します。

# components/TodoList.vue
<template>
  <div>
      <h1>{{ message }}</h1>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'

@Component
export default class TodosList extends Vue {
  message: string = 'Todo List 😎'
}
</script>

最後に、pages/index.vue から呼び出していた HelloWorld コンポーネントと Todos コンポーネントを置き換えます。

# pages/index.vue
<template>
  <Todos />
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import TodoList from '~/components/TodoList.vue';

@Component({
  components: {
    TodoList
  }
})
export default class Home extends Vue {}
</script>

この段階で npm run dev を実行して localhost:3000 にアクセスすると、下記のような表示になっているはずです。

キリの良いところまで来たので一旦コミットしておきます。

$ git add .
$ git commit -m 'Add Store and Todo Component'


4. TodoList を表示する

まず全ての Todo を取得する Getter を用意します。

# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree } from 'vuex';

export const name = 'todos';
export const namespaced = true;

export const state = (): State => ({
  todos: []
});

export const getters: GetterTree<State, RootState> = {
  allTodos: state => {
      return state.todos;
  }
}

次に、Todo 一つ一つを表示するコンポーネントを作成します。

# components/Todo.vue
<template>
  <div>
      <p>{{ todo.content }}</p>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'

@Component
export default class Todo extends Vue {
  @Prop() todo
}
</script>

最後に、TodoList.vue を修正します。

# components/TodoList.vue
<template>
  <div>
      <ul v-for="todo in allTodos" :key='todo.id'>
          <li>
              <Todo :todo="todo" />
          </li>
      </ul>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import Todo from '~/components/Todo.vue';
import { namespace } from 'vuex-class';
import * as todos from '~/store/modules/todos';

const Todos = namespace(todos.name);

@Component({
    components: {
        Todo
    }
})
export default class TodoList extends Vue {
    @Todos.Getter allTodos
}
</script>

※「Classic mode for store/ is deprecated and will be removed in Nuxt 3.」と警告が出ますが、これは下記のモジュールモードに関する記述が関連あるようです。要はもっと簡潔にモジュールを表現できるようなのですが、TypeScriptでは上手くモジュールモードを利用できなかったのと、公式のサンプルでもここに書いてあるのと同じクラシックモードで書かれていたので、一旦警告はスルーします。恐らく、しばらく待ったらまた知見が公開されるはずです。


ここで、npm run dev を実行すると、localhost:3000 に真っ白のページが表示されるはずです。これは、まだ Todo が一つも存在しないからです。

6. Todoの作成

まず Todo の interfaceを実装した TodlClass を作成します。

# store/modules/todoTypes.ts
export interface State {
    todos: Todo[]
}

export interface Todo {
    id: number
    content: string
}

export class TodoClass implements Todo {
    id: number;
    content: string;
  
    constructor(id: number, content: string) {
      this.id = id;
      this.content = content;
    }
}

次に、 Todo を追加する Action と Mutation を定義します。

# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree, ActionTree, ActionContext, MutationTree } from 'vuex';
import { TodoClass } from './todoTypes';

export const name = 'todos';
export const namespaced = true;

export const state = (): State => ({
  todos: []
});

export const getters: GetterTree<State, RootState> = {
  allTodos: state => {
      return state.todos;
  }
}

export const types = {
  ADDTODO: 'ADDTODO'
}

export interface Actions<S, R> extends ActionTree<S, R> {
  addTodo (context: ActionContext<S, R>, document): void
}

export const actions: Actions<State, RootState> = {
  addTodo ({ commit }, document) {
    const target: HTMLInputElement = <HTMLInputElement>document.target;
    console.log(target.value);
    commit(types.ADDTODO, target.value)
    target.value = '';
  }
}

export const mutations: MutationTree<State> = {
  [types.ADDTODO] (state, content: string) {
    let id = 0
    if (state.todos.length > 0) {
      id = state.todos[state.todos.length - 1].id + 1;
    }
    state.todos.push(new TodoClass(id, content));
  }
}


次に、NewTodo.vue を追加します。

# components/NewTodo.vue
<template>
    <div>
        <h2>Todo Input</h2>
        <input type="text" @keyup.enter="addTodo"/>
    </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import * as todos from '~/store/modules/todos'
import { namespace } from 'vuex-class'
const Todos = namespace(todos.name)

@Component
export default class NewTodo extends Vue {
  @Todos.Action addTodo
}
</script>


最後に、pages/index.vue を修正し、NewTodo コンポーネントを追加します。

# pages/index.vue
<template>
  <div>
    <NewTodo />
    <TodoList />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import TodoList from '~/components/TodoList.vue';
import NewTodo from '~/components/NewTodo.vue';
import * as todos from '~/store/modules/todos';

@Component({
  components: {
    TodoList,
    NewTodo
  }
})
export default class Home extends Vue {
}
</script>


一旦 npm run dev で起動してみます。localhost:3000 にアクセスすると、下記のような画面が現れます。適当な文字を入力してエンターを押すと、リストにTodoが追加されていきます。


7. DONEボタンの追加

まず REMOVETODO のActionとMutationを追加します。

# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree, ActionTree, ActionContext, MutationTree } from 'vuex';
import { TodoClass } from './todoTypes';

export const name = 'todos';
export const namespaced = true;

export const state = (): State => ({
  todos: []
});

export const getters: GetterTree<State, RootState> = {
  allTodos: state => {
      return state.todos;
  }
}

export const types = {
  ADDTODO: 'ADDTODO',
  REMOVETODO: 'REMOVETODO',
}

export interface Actions<S, R> extends ActionTree<S, R> {
  addTodo (context: ActionContext<S, R>, document): void
  removeTodo (context: ActionContext<S, R>, id: number): void
}

export const actions: Actions<State, RootState> = {
  addTodo ({ commit }, document) {
    const target: HTMLInputElement = <HTMLInputElement>document.target;
    console.log(target.value);
    commit(types.ADDTODO, target.value)
    target.value = '';
  },

  removeTodo ({ commit }, id: number) {
    commit(types.REMOVETODO, id)
  }
}

export const mutations: MutationTree<State> = {
  [types.ADDTODO] (state, content: string) {
    let id = 0
    if (state.todos.length > 0) {
      id = state.todos[state.todos.length - 1].id + 1;
    }
    state.todos.push(new TodoClass(id, content, false));
  },

  [types.REMOVETODO] (state, id: number) {
    // 各Todoはidを持っており、そのidを持つTodoのindexを取得する。
    const todoIndex = state.todos.findIndex(todo => todo.id == id);
    state.todos.splice(todoIndex, 1);
  }
}


次に、Todo.vue に removeTodo とDONEボタンを追加します。

# components/Todo.vue
<template>
  <div>
      <p>{{ todo.content }}</p>
      <button @click="removeTodo(todo.id)">DONE</button>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import * as todos from '~/store/modules/todos'
import { namespace } from 'vuex-class'

const Todos = namespace(todos.name)

@Component
export default class Todo extends Vue {
  @Prop() todo

  @Todos.Action removeTodo
}
</script>

DONEボタンを押すとTodoがリストから削除できるようになりました。


8. Todoの編集

まず、Todo の interface とクラスに isEditing: boolean を追加します。EDITボタンで各Todoの状態をToggleさせ、現在が編集状態であるか、そうでないかを表現します。

# store/modules/todoTypes.ts
export interface Todo {
    id: number
    content: string
    isEditing: boolean
}

export class TodoClass implements Todo {
    id: number;
    content: string;
    isEditing: boolean;
  
    constructor(id: number, content: string, isEditing: boolean) {
      this.id = id;
      this.content = content;
      this.isEditing = isEditing;
    }
}
  
export interface State {
    todos: Todo[]
}


次に、Todoの状態をToggleさせる編集ボタンと、inputに文字を入力してエンターを押した時に発火しTodoの内容を更新する Update に対応する、ActionとMutationを追加します。

# store/modules/todos.ts
import { State } from './todoTypes';
import { RootState } from 'store'
import { GetterTree, ActionTree, ActionContext, MutationTree } from 'vuex';
import { Todo, TodoClass } from './todoTypes';

export const name = 'todos';
export const namespaced = true;

export const state = (): State => ({
  todos: []
});

export const getters: GetterTree<State, RootState> = {
  allTodos: state => {
      return state.todos;
  }
}

export const types = {
  ADDTODO: 'ADDTODO',
  REMOVETODO: 'REMOVETODO',
  EDITTODO: 'EDITTODO',
  UPDATETODO: 'UPDATETODO'
}

export interface Actions<S, R> extends ActionTree<S, R> {
  addTodo (context: ActionContext<S, R>, document): void
  removeTodo (context: ActionContext<S, R>, id: number): void
  editTodo (context: ActionContext<S, R>, id: number): void
  updateTodo (context: ActionContext<S, R>, document): void
}

export const actions: Actions<State, RootState> = {
  addTodo ({ commit }, document) {
    const target: HTMLInputElement = <HTMLInputElement>document.target;
    console.log(target.value);
    commit(types.ADDTODO, target.value)
    target.value = '';
  },

  removeTodo ({ commit }, id: number) {
    commit(types.REMOVETODO, id)
  },

  editTodo ({ commit }, id: number) {
    commit(types.EDITTODO, id)
  },

  updateTodo ({ commit }, document) {
    const target: HTMLInputElement = <HTMLInputElement>document.target;
    const todo: TodoClass = new TodoClass(Number(target.id), target.value, false)
    commit(types.UPDATETODO, todo);
    target.value = '';
  }
}

export const mutations: MutationTree<State> = {
  [types.ADDTODO] (state, content: string) {
    let id = 0
    if (state.todos.length > 0) {
      id = state.todos[state.todos.length - 1].id + 1;
    }
    state.todos.push(new TodoClass(id, content, false));
  },

  [types.REMOVETODO] (state, id: number) {
    // 各Todoはidを持っており、そのidを持つTodoのindexを取得する。
    const todoIndex = state.todos.findIndex(todo => todo.id == id);
    state.todos.splice(todoIndex, 1);
  },

  [types.EDITTODO] (state, id: number) {
    const todoIndex = state.todos.findIndex(todo => todo.id == id);
    state.todos[todoIndex].isEditing = true;
  },

  [types.UPDATETODO] (state, todo: Todo) {
    const todoIndex = state.todos.findIndex(el => el.id == todo.id);
    // update前の要素を削除
    state.todos.splice(todoIndex, 1);

    // 新しい要素を同じ index に追加
    state.todos.splice(todoIndex, 0, todo);
  }
}


最後に、components/Todo.vue を修正します。

# components/Todo.vue
<template>
  <div>
      <div v-show="!todo.isEditing">
          <p>Text : {{ todo.content }}</p>
          <button @click="editTodo(todo.id)">Edit</button>
      </div>
      <div v-show="todo.isEditing">
          <input :id="todo.id" type="text" @keyup.enter="updateTodo" :value="todo.content"/>
      </div>

      <button @click="removeTodo(todo.id)">DONE</button>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import * as todos from '~/store/modules/todos'
import { namespace } from 'vuex-class'

const Todos = namespace(todos.name)

@Component
export default class Todo extends Vue {
  @Prop() todo

  @Todos.Action removeTodo
  @Todos.Action editTodo
  @Todos.Action updateTodo
}
</script>


ついに完成しました! 完成物は冒頭の動画です。

次の Part3 では、もう少しデザインを整えていこうと思います。(_ _).。o○








この記事が気に入ったらサポートをしてみませんか?