Chapter 03

Vueでフロント実装してみる

matsu7089
matsu7089
2021.01.01に更新

環境構築お疲れ様でした!いよいよ実装に入っていきます!

はじめに、ディレクトリ構成について軽く把握しておきましょう。
src ディレクトリの中はざっとこんな感じになっています。

assets/ ロゴなどのアセット
components/ 主に再利用するvueコンポーネント
plugins/ vuetify などのプラグイン
router/ ルーティングの設定
store/ Vuexストアの設定
views/ ページを構成するvueファイル
App.vue Vueアプリのメインファイル
main.js エントリポイントとなるファイル

App.vue を書き換えてみる

さっそくですが、メインファイルである App.vue が自動生成された状態のままなので、
不要なものを消してシンプルにします。

App.vue
<template>
  <v-app>
    <!-- ツールバー -->
    <v-app-bar app color="green" dark>
      <!-- タイトル -->
      <v-toolbar-title>GAS 家計簿</v-toolbar-title>
      <v-spacer></v-spacer>
      <!-- テーブルアイコンのボタン -->
      <v-btn icon to="/">
        <v-icon>mdi-file-table-outline</v-icon>
      </v-btn>
      <!-- 歯車アイコンのボタン -->
      <v-btn icon to="/settings">
        <v-icon>mdi-cog</v-icon>
      </v-btn>
    </v-app-bar>
    <!-- メインコンテンツ -->
    <v-main>
      <v-container fluid>
        <!-- router-view の中身がパスによって切り替わる -->
        <router-view></router-view>
      </v-container>
    </v-main>
  </v-app>
</template>
<script>
export default {
  name: 'App'
}
</script>

上部に緑色のツールバーが表示されました。

ツールバーに表示されたボタンを押すと画面が切り替わると思います。
これは、v-btnto 属性を設定すると、ボタンが押されたときにそのパスへ移動できるからです。

また、v-iconMaterial Design Icons が使えます。
使い方は mdi-アイコン名v-icon の中身に書くだけです。

App.vue|9-14行目
<!-- テーブルアイコンのボタン -->
<v-btn icon to="/"> <!-- クリックで "/" へ移動する -->
  <v-icon>mdi-file-table-outline</v-icon>
</v-btn>
<!-- 歯車アイコンのボタン -->
<v-btn icon to="/settings"> <!-- クリックで "/settings" へ移動する -->
  <v-icon>mdi-cog</v-icon>
</v-btn>

URL のパスによって、この router-view の中身が切り替わります。
/ は最初に表示されていた画面(Welcome to Vuetify)、
/settings はまだ作っていないので、何もない画面に切り替わります。

App.vue|20-21行目
<!-- router-view の中身がパスによって切り替わる -->
<router-view></router-view>

ルーティングの設定は src/router/index.js に書かれています。
このファイルを見てみましょう。

router/index.js|7-12行目
const routes = [
  {
    path: '/',      // パスが "/" のときの設定
    name: 'Home',   // このルートに "Home" という名前をつける
    component: Home // router-view の中に Home コンポーネントを表示する
  },

この Home コンポーネントは、3行目で読み込まれています。
/ では src/views/Home.vue を表示しているようですね!

router/index.js|3行目
import Home from '../views/Home.vue'

ここまでの大雑把な流れは、
App.vue -> router -> views
ということがわかりました!

ページの中身を書き換えてみる

では、ページを中身を書き換えてみます。
ついでに views ディレクトリの中に Settings.vue も作りましょう。
どちらも中身はシンプルにします。

Home.vue
<template>
  <div>
    <h1>Home だよ</h1>
  </div>
</template>
<script>
export default {
  name: 'Home'
}
</script>
Settings.vue
<template>
  <div>
    <h1>Settings だよ</h1>
  </div>
</template>
<script>
export default {
  name: 'Settings'
}
</script>

ルーティングの設定を変えて、HomeSettings が表示されるようにします。

router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Settings from '../views/Settings.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/settings',
    name: 'Settings',
    component: Settings
  }
]

const router = new VueRouter({
  routes
})

export default router

このように表示が切り替われば大丈夫です。

ホームの画面だけ実装してみる

それでは、ホームの画面だけ実装していきましょう。
月選択フォーム、データ追加ボタン、検索フォーム、テーブル
の4つを作っていきます。

Home.vue
<template>
  <div>
    <v-card>
      <v-card-title>
        <!-- 月選択 -->
        <v-col cols="8">
          <v-menu 
            ref="menu"
            v-model="menu"
            :close-on-content-click="false"
            :return-value.sync="yearMonth"
            transition="scale-transition"
            offset-y
            max-width="290px"
            min-width="290px"
          >
            <template v-slot:activator="{ on }">
              <v-text-field
                v-model="yearMonth"
                prepend-icon="mdi-calendar"
                readonly
                v-on="on"
                hide-details
              />
            </template>
            <v-date-picker
              v-model="yearMonth"
              type="month"
              color="green"
              locale="ja-jp"
              no-title
              scrollable
            >
              <v-spacer/>
              <v-btn text color="grey" @click="menu = false">キャンセル</v-btn>
              <v-btn text color="primary" @click="$refs.menu.save(yearMonth)">選択</v-btn>
            </v-date-picker>
          </v-menu>
        </v-col>
        <v-spacer/>
        <!-- 追加ボタン -->
        <v-col class="text-right" cols="4">
          <v-btn dark color="green">
            <v-icon>mdi-plus</v-icon>
          </v-btn>
        </v-col>
        <!-- 検索フォーム -->
        <v-col cols="12">
          <v-text-field
            v-model="search"
            append-icon="mdi-magnify"
            label="Search"
            single-line
            hide-details
          />
        </v-col>
      </v-card-title>
      <!-- テーブル -->
      <v-data-table
        class="text-no-wrap"
        :headers="tableHeaders"
        :items="tableData"
        :search="search"
        :footer-props="footerProps"
        :loading="loading"
        :sort-by="'date'"
        :sort-desc="true"
        :items-per-page="30"
        mobile-breakpoint="0"
      >
      </v-data-table>
    </v-card>
  </div>
</template>
<script>
export default {
  name: 'Home',

  data () {
    const today = new Date()
    const year = today.getFullYear()
    const month = ('0' + (today.getMonth() + 1)).slice(-2)

    return {
      /** ローディング状態 */
      loading: false,
      /** 月選択メニューの状態 */
      menu: false,
      /** 検索文字 */
      search: '',
      /** 選択年月 */
      yearMonth: `${year}-${month}`,
      /** テーブルに表示させるデータ */
      tableData: [
        /** サンプルデータ */
        { id: 'a34109ed', date: '2020-06-01', title: '支出サンプル', category: '買い物', tags: 'タグ1', income: null, outgo: 2000, memo: 'メモ' },
        { id: '7c8fa764', date: '2020-06-02', title: '収入サンプル', category: '給料', tags:'タグ1,タグ2', income: 2000, outgo: null, memo: 'メモ' }
      ]
    }
  },

  computed: {
    /** テーブルのヘッダー設定 */
    tableHeaders () {
      return [
        { text: '日付', value: 'date', align: 'end' },
        { text: 'タイトル', value: 'title', sortable: false },
        { text: 'カテゴリ', value: 'category', sortable: false },
        { text: 'タグ', value: 'tags', sortable: false },
        { text: '収入', value: 'income', align: 'end' },
        { text: '支出', value: 'outgo', align: 'end' },
        { text: 'メモ', value: 'memo', sortable: false },
        { text: '操作', value: 'actions', sortable: false }
      ]
    },

    /** テーブルのフッター設定 */
    footerProps () {
      return { itemsPerPageText: '', itemsPerPageOptions: [] }
    }
  }
}
</script>

こんな感じになればOKです。

…いきなり長いコードになってしまいました。🙇‍♂️
重要だと思うところを説明します。

検索フォームでは v-model を使って入力されたデータを同期させています。
この場合は this.search で入力された内容を読み取ることができます。

Home.vue|47-56行目
<!-- 検索フォーム -->
<v-col cols="12">
  <v-text-field
    v-model="search"          入力したデータを this.search と同期
    append-icon="mdi-magnify" 検索アイコン
    label="Search"            ラベル名
    single-line               1行だけ入力できる
    hide-details              文字カウントなどを非表示
  />
</v-col>

テーブルにはさまざまなプロパティを設定できます。
今回設定したものはこんな感じです。

Home.vue|58-70行目
<!-- テーブル -->
<v-data-table
  class="text-no-wrap"        文字を折り返さないようにするクラス
  :headers="tableHeaders"     ヘッダー設定
  :items="tableData"          テーブルに表示するデータ
  :search="search"            検索する文字
  :footer-props="footerProps" フッター設定
  :loading="loading"          ローディング状態
  :sort-by="'date'"           ソート初期設定(列名)
  :sort-desc="true"           ソート初期設定(降順)
  :items-per-page="30"        テーブルに最大何件表示するか
  mobile-breakpoint="0"       モバイル表示にさせる画面サイズ(今回はモバイル表示にさせたくないので 0 を設定)
>

headers にヘッダーの設定、items に表示するデータを入れるという感じです。
ヘッダーの設定の中身をみてみます。

text には表示させる列名、 value には表示させるデータのキーを設定します。
たとえば、 { text: '日付', value: 'date' }
「日付列にはデータの date を表示する」という設定になります。
また、 align でテキストの寄せる方向、 sortable でソート可否を設定できます。

views/Home.vue|104-116行目
/** テーブルのヘッダー設定 */
tableHeaders () {
  return [
    { text: '日付', value: 'date', align: 'end' },
    { text: 'タイトル', value: 'title', sortable: false },
    { text: 'カテゴリ', value: 'category', sortable: false },
    { text: 'タグ', value: 'tags', sortable: false },
    { text: '収入', value: 'income', align: 'end' },
    { text: '支出', value: 'outgo', align: 'end' },
    { text: 'メモ', value: 'memo', sortable: false },
    { text: '操作', value: 'actions', sortable: false }
  ]
},

一応サンプルデータが表示されていますが、
日付やタグの表示、収支を3桁区切りにしたいですよね。
次にこれを実装します。

~ 省略 ~ の部分に変更はありません。

Home.vue
<!-- ~ 省略 ~ -->
<!-- テーブル -->
<v-data-table
   省略 
>
  <!-- 日付列 -->
  <template v-slot:item.date="{ item }">
    {{ parseInt(item.date.slice(-2)) + '日' }}
  </template>
  <!-- タグ列 -->
  <template v-slot:item.tags="{ item }">
    <div v-if="item.tags">
      <v-chip
        class="mr-2"
        v-for="(tag, i) in item.tags.split(',')"
        :key="i"
      >
        {{ tag }}
      </v-chip>
    </div>
  </template>
  <!-- 収入列 -->
  <template v-slot:item.income="{ item }">
    {{ separate(item.income) }}
  </template>
  <!-- タグ列 -->
  <template v-slot:item.outgo="{ item }">
    {{ separate(item.outgo) }}
  </template>
  <!-- 操作列 -->
  <template v-slot:item.actions="{}">
    <v-icon class="mr-2">mdi-pencil</v-icon>
    <v-icon>mdi-delete</v-icon>
  </template>
</v-data-table>
<!-- ~ 省略 ~ -->
/** ~ 省略 ~ */
<script>
export default {
  name: 'Home',
  data () {
    /** ~ 省略 ~ */
  },
  computed: {
    /** ~ 省略 ~ */ 
  },
  methods: {
    /**
     * 数字を3桁区切りにして返します。
     * 受け取った数が null のときは null を返します。
     */
    separate (num) {
      return num !== null ? num.toString().replace(/(\d)(?=(\d{3})+$)/g, '$1,') : null
    }
  }
}
</script>

一気にそれっぽくなりました。

これは Vuetify の決まりごとになってしまいますが、
v-data-table 内の template で v-slot:item.列名="{ item }" とすると、その列のデータを加工できます。

<!-- 日付列 -->
<template v-slot:item.date="{ item }">
  <!-- この中で、日付は item.date でアクセスできる -->
  <!-- '2020-06-01' → '1日' に加工 -->
  {{ parseInt(item.date.slice(-2)) + '日' }}
</template>

現時点のソースコード一覧はこちらから確認できます!

操作ダイアログを作る

データを追加/編集するダイアログを作ります。
新しく components ディレクトリの中に ItemDialog.vue を作成します。

ItemDialog.vue
<template>
  <!-- データ追加/編集ダイアログ -->
  <v-dialog
    v-model="show"
    scrollable
    persistent
    max-width="500px"
    eager
  >
    <v-card>
      <v-card-title>{{ titleText }}</v-card-title>
      <v-divider/>
      <v-card-text>
        <v-form ref="form" v-model="valid">
          <!-- 日付選択 -->
          <v-menu
            ref="menu"
            v-model="menu"
            :close-on-content-click="false"
            :return-value.sync="date"
            transition="scale-transition"
            offset-y
            max-width="290px"
            min-width="290px"
          >
            <template v-slot:activator="{ on }">
              <v-text-field
                v-model="date"
                prepend-icon="mdi-calendar"
                readonly
                v-on="on"
                hide-details
              />
            </template>
            <v-date-picker
              v-model="date"
              color="green"
              locale="ja-jp"
              :day-format="date => new Date(date).getDate()"
              no-title
              scrollable
            >
              <v-spacer/>
              <v-btn text color="grey" @click="menu = false">キャンセル</v-btn>
              <v-btn text color="primary" @click="$refs.menu.save(date)">選択</v-btn>
            </v-date-picker>
          </v-menu>
          <!-- タイトル -->
          <v-text-field
            label="タイトル"
            v-model.trim="title"
            :counter="20"
            :rules="titleRules"
          />
          <!-- 収支 -->
          <v-radio-group
            row
            v-model="inout"
            hide-details
            @change="onChangeInout"
          >
            <v-radio label="収入" value="income"/>
            <v-radio label="支出" value="outgo"/>
          </v-radio-group>
          <!-- カテゴリ -->
          <v-select
            label="カテゴリ"
            v-model="category"
            :items="categoryItems"
            hide-details
          />
          <!-- タグ -->
          <v-select
            label="タグ"
            v-model="tags"
            :items="tagItems"
            multiple
            chips
            :rules="[tagRule]"
          />
          <!-- 金額 -->
          <v-text-field
            label="金額"
            v-model.number="amount"
            prefix=""
            pattern="[0-9]*"
            :rules="amountRules"
          />
          <!-- メモ -->
          <v-text-field
            label="メモ"
            v-model="memo"
            :counter="50"
            :rules="[memoRule]"
          />
        </v-form>
      </v-card-text>
      <v-divider/>
      <v-card-actions>
        <v-spacer/>
        <v-btn
          color="grey darken-1"
          text
          :disabled="loading"
          @click="onClickClose"
        >
          キャンセル
        </v-btn>
        <v-btn
          color="blue darken-1"
          text
          :disabled="!valid"
          :loading="loading"
          @click="onClickAction"
        >
          {{ actionText }}
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>
<script>
export default {
  name: 'ItemDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** 入力したデータが有効かどうか */
      valid: false,
      /** 日付選択メニューの表示状態 */
      menu: false,
      /** ローディング状態 */
      loading: false,

      /** 操作タイプ 'add' or 'edit' */
      actionType: 'add',
      /** id */
      id: '',
      /** 日付 */
      date: '',
      /** タイトル */
      title: '',
      /** 収支 'income' or 'outgo' */
      inout: '',
      /** カテゴリ */
      category: '',
      /** タグ */
      tags: [],
      /** 金額 */
      amount: 0,
      /** メモ */
      memo: '',

      /** 収支カテゴリ一覧 */
      incomeItems: ['カテ1', 'カテ2'],
      outgoItems: ['カテ3', 'カテ4'],
      /** 選択カテゴリ一覧 */
      categoryItems: [],
      /** タグリスト */
      tagItems: ['タグ1', 'タグ2'],
      /** 編集前の年月(編集時に使う) */
      beforeYM: '',

      /** バリデーションルール */
      titleRules: [
        v => v.trim().length > 0 || 'タイトルは必須です',
        v => v.length <= 20 || '20文字以内で入力してください'
      ],
      tagRule: v => v.length <= 5 || 'タグは5種類以内で選択してください',
      amountRules: [
        v => v >= 0 || '金額は0以上で入力してください',
        v => Number.isInteger(v) || '整数で入力してください'
      ],
      memoRule: v => v.length <= 50 || 'メモは50文字以内で入力してください'
    }
  },

  computed: {
    /** ダイアログのタイトル */
    titleText () {
      return this.actionType === 'add' ? 'データ追加' : 'データ編集'
    },
    /** ダイアログのアクション */
    actionText () {
      return this.actionType === 'add' ? '追加' : '更新'
    }
  },

  methods: {
    /**
     * ダイアログを表示します。
     * このメソッドは親から呼び出されます。
     */
    open (actionType, item) {
      this.show = true
      this.actionType = actionType
      this.resetForm(item)

      if (actionType === 'edit') {
        this.beforeYM = item.date.slice(0, 7)
      }
    },
    /** キャンセルがクリックされたとき */
    onClickClose () {
      this.show = false
    },
    /** 追加/更新がクリックされたとき */
    onClickAction () {
      // あとで実装
    },
    /** 収支が切り替わったとき */
    onChangeInout () {
      if (this.inout === 'income') {
        this.categoryItems = this.incomeItems
      } else {
        this.categoryItems = this.outgoItems
      }
      this.category = this.categoryItems[0]
    },
    /** フォームの内容を初期化します */
    resetForm (item = {}) {
      const today = new Date()
      const year = today.getFullYear()
      const month = ('0' + (today.getMonth() + 1)).slice(-2)
      const date = ('0' + today.getDate()).slice(-2)

      this.id = item.id || ''
      this.date = item.date || `${year}-${month}-${date}`
      this.title = item.title || ''
      this.inout = item.income != null ? 'income' : 'outgo'

      if (this.inout === 'income') {
        this.categoryItems = this.incomeItems
        this.amount = item.income || 0
      } else {
        this.categoryItems = this.outgoItems
        this.amount = item.outgo || 0
      }

      this.category = item.category || this.categoryItems[0]
      this.tags = item.tags ? item.tags.split(',') : []
      this.memo = item.memo || ''

      this.$refs.form.resetValidation()
    }
  }
}
</script>

…重要だと思うところを説明します。

ホーム画面の検索フォームと同じように、v-text-field を使っています。
rules を設定するだけで、いい感じにバリデーションしてくれます。

ItemDialog.vue|48-54行目
<!-- タイトル -->
<v-text-field
  label="タイトル"
  v-model.trim="title"
  :counter="20"
  :rules="titleRules"
/>
// バリデーションルールの書き方
// v には現在入力されているデータが入ってる
v => /** OKにする条件 */ || /** NGのときに表示させる文字 */

ルールはこのように複数設定できます。

ItemDialog.vue|168-171行目
titleRules: [
  v => v.trim().length > 0 || 'タイトルは必須です',
  v => v.length <= 20 || '20文字以内で入力してください'
],

現状のままだとダイアログの動作確認できないので、
ホーム画面でダイアログを表示できるように ItemDialog.vue をインポートします。

Home.vue
<template>
  <div>
    <v-card>
      <v-card-title>
        <!-- ~ 省略 ~ -->
        <!-- 追加ボタン -->
        <v-col class="text-right" cols="4">
          <v-btn dark color="green" @click="onClickAdd">
            <v-icon>mdi-plus</v-icon>
          </v-btn>
        </v-col>
        <!-- ~ 省略 ~ -->
      </v-card-title>
      <!-- テーブル -->
      <v-data-table>
        <!-- ~ 省略 ~ -->
        <!-- 操作列 -->
        <template v-slot:item.actions="{ item }">
          <v-icon class="mr-2" @click="onClickEdit(item)">mdi-pencil</v-icon>
          <v-icon>mdi-delete</v-icon>
        </template>
      </v-data-table>
    </v-card>
    <!-- 追加/編集ダイアログ -->
    <ItemDialog ref="itemDialog"/>
  </div>
</template>
<script>
import ItemDialog from '../components/ItemDialog.vue'

export default {
  name: 'Home',
  components: {
    ItemDialog
  },

  /** ~ 省略 ~ */

  methods: {
    /** ~ 省略 ~ */
    /** 追加ボタンがクリックされたとき */
    onClickAdd () {
      this.$refs.itemDialog.open('add')
    },
    /** 編集ボタンがクリックされたとき */
    onClickEdit (item) {
      this.$refs.itemDialog.open('edit', item)
    }
  }
}
</script>

テーブル右上に表示されている追加ボタン、
操作列の編集ボタンをクリックして、動作を確認してみます。

追加ボタンをクリックしたときは何も入力されていないフォーム、
編集ボタンをクリックしたときは初期値が入力されているフォームが表示されればOKです。

バリデーションも実行されるか確認してみます。
問題なく動いてそうです。

コンポーネントの子要素には ref 属性をつけると this.$refs.名前 でアクセスできます。

<!-- 追加/編集ダイアログ -->
<ItemDialog ref="itemDialog"/>

今回はダイアログに itemDialog という名前をつけたので、 this.$refs.itemDialog ですね。

追加ボタンをクリックしたとき、追加/編集ダイアログの open を実行することで
ダイアログの表示を行うようにしています。

/** 追加ボタンがクリックされたとき */
onClickAdd () {
  this.$refs.itemDialog.open('add')
},

追加/編集ダイアログと同じように削除ダイアログも作成します。
新しく components ディレクトリの中に DeleteDialog.vue を作成します。
コードは少なめです😊

DeleteDialog.vue
<template>
  <!-- 削除ダイアログ -->
  <v-dialog
    v-model="show"
    persistent
    max-width="290"
  >
    <v-card>
      <v-card-title/>
      <v-card-text class="black--text">
        「{{ item.title }}」を削除しますか?
      </v-card-text>
      <v-card-actions>
        <v-spacer/>
        <v-btn color="grey" text :disabled="loading" @click="onClickClose">キャンセル</v-btn>
        <v-btn color="red" text :loading="loading" @click="onClickDelete">削除</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>
<script>
export default {
  name: 'DeleteDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** ローディング状態 */
      loading: false,
      /** 受け取ったデータ */
      item: {}
    }
  },

  methods: {
    /**
     * ダイアログを表示します。
     * このメソッドは親から呼び出されます。
     */    
    open (item) {
      this.show = true
      this.item = item
    },
    /** キャンセルがクリックされたとき */
    onClickClose () {
      this.show = false
    },
    /** 削除がクリックされたとき */
    onClickDelete () {
      // あとで実装
    }
  }
}
</script>

追加/編集ダイアログと同じように、ホームで表示させます。

Home.vue
    <!-- ~ 省略 ~ -->
    </v-card>
    <!-- 追加/編集ダイアログ -->
    <ItemDialog ref="itemDialog"/>
    <!-- 削除ダイアログ -->
    <DeleteDialog ref="deleteDialog"/>
  </div>
</template>
<script>
import ItemDialog from '../components/ItemDialog.vue'
import DeleteDialog from '../components/DeleteDialog.vue'

export default {
  name: 'Home',

  components: {
    ItemDialog,
    DeleteDialog
  },

  /** ~ 省略 ~ */

  methods: {
    /** ~ 省略 ~ */
    /** 削除ボタンがクリックされたとき */
    onClickDelete (item) {
      this.$refs.deleteDialog.open(item)
    }
  }
}
</script>

削除ボタンをクリックして、ダイアログが表示されればOkです。

現時点のソースコード一覧はこちらから確認できます!

設定の画面だけ作る

次に、手をつけていなかった設定画面を作ります。

Settings.vue
<template>
  <div class="form-wrapper">
    <p>※設定はこのデバイスのみに保存されます。</p>
    <v-form v-model="valid">
      <h3>アプリ設定</h3>
      <!-- アプリ名 -->
      <v-text-field
        label="アプリ名"
        v-model="settings.appName"
        :counter="30"
        :rules="[appNameRule]"
      />
      <!-- API URL -->
      <v-text-field
        label="API URL"
        v-model="settings.apiUrl"
        :counter="150"
        :rules="[stringRule]"
      />
      <!-- Auth Token -->
      <v-text-field
        label="Auth Token"
        v-model="settings.authToken"
        :counter="150"
        :rules="[stringRule]"
      />
      <h3>カテゴリ/タグ設定</h3>
      <p>カンマ( &#44; )区切りで入力してください。</p>
      <!-- 収入カテゴリ -->
      <v-text-field
        label="収入カテゴリ"
        v-model="settings.strIncomeItems"
        :counter="150"
        :rules="[stringRule, ...categoryRules]"
      />
      <!-- 支出カテゴリ -->
      <v-text-field
        label="支出カテゴリ"
        v-model="settings.strOutgoItems"
        :counter="150"
        :rules="[stringRule, ...categoryRules]"
      />
      <!-- タグ -->
      <v-text-field
        label="タグ"
        v-model="settings.strTagItems"
        :counter="150"
        :rules="[stringRule, tagRule]"
      />
      <v-row class="mt-4">
        <v-spacer/>
        <v-btn color="primary" :disabled="!valid" @click="onClickSave">保存</v-btn>
      </v-row>
    </v-form>
  </div>
</template>
<script>
export default {
  name: 'Settings',

  data () {
    const createItems = v => v.split(',').map(v => v.trim()).filter(v => v.length !== 0)
    const itemMaxLength = v => createItems(v).reduce((a, c) => Math.max(a, c.length), 0)

    return {
      /** 入力したデータが有効かどうか */
      valid: false,
      /** 設定 */
      settings: {
        appName: 'GAS 家計簿',
        apiUrl: '',
        authToken: '',
        strIncomeItems: '給料, ボーナス, 繰越',
        strOutgoItems: '食費, 趣味, 交通費, 買い物, 交際費, 生活費, 住宅, 通信, 車, 税金',
        strTagItems: '固定費, カード'
      },

      /** バリデーションルール */
      appNameRule: v => v.length <= 30 || '30文字以内で入力してください',
      stringRule: v => v.length <= 150 || '150文字以内で入力してください',
      categoryRules: [
        v => createItems(v).length !== 0 || 'カテゴリは1つ以上必要です',
        v => itemMaxLength(v) <= 4 || '各カテゴリは4文字以内で入力してください'
      ],
      tagRule: v => itemMaxLength(v) <= 4 || '各タグは4文字以内で入力してください'
    }
  },

  methods: {
    onClickSave () {
      // あとで実装
    }
  }
}
</script>

<style>
.form-wrapper {
  max-width: 500px;
  margin: auto;
}
</style>

追加/編集ダイアログと同じようにフォームを表示させ、バリデーションさせています。

スプレッド構文を使うと、いい感じにバリデーションルールを使い回せます。

const rules = ['rule2', 'rule3']
console.log(['rule1', ...rules]) // -> ['rule1', 'rule2', 'rule3']
Settings.vue|29-35行目
<!-- 収入カテゴリ -->
<v-text-field
  label="収入カテゴリ"
  v-model="settings.strIncomeItems"
  :counter="150"
  :rules="[stringRule, ...categoryRules]"
/>

設定を保存/読み込みできるようにする

設定画面で保存ボタンを押しても入力したデータは保存されていません。
また、この状態だとホーム画面で設定を読み込むこともできません。

ここで登場するのが Vuex です。状態(State)を管理できます。
公式ドキュメントにある画像がわかりやすかったので引用します。

とても大雑把に説明すると、
「画面から Actions を使って状態更新」→「State から状態読み込み」という流れになります。

今回は「設定」「家計簿データ」の状態管理に Vuex を使用します。
さっそく、設定を保存/読み込みできるよう src/store/index.js を書き換えます。
設定の内容は永続的に保存したいので、localStorage を利用します。

store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

/** 
 * State
 * Vuexの状態
 */
const state = {
  /** 設定 */
  settings: {
    appName: 'GAS 家計簿',
    apiUrl: '',
    authToken: '',
    strIncomeItems: '給料, ボーナス, 繰越',
    strOutgoItems: '食費, 趣味, 交通費, 買い物, 交際費, 生活費, 住宅, 通信, 車, 税金',
    strTagItems: '固定費, カード'
  }
}

/**
 * Mutations
 * ActionsからStateを更新するときに呼ばれます
 */
const mutations = {
  /** 設定を保存します */
  saveSettings (state, { settings }) {
    state.settings = { ...settings }
    document.title = state.settings.appName

    localStorage.setItem('settings', JSON.stringify(settings))
  },

  /** 設定を読み込みます */
  loadSettings (state) {
    const settings = JSON.parse(localStorage.getItem('settings'))
    if (settings) {
      state.settings = Object.assign(state.settings, settings)
    }
    document.title = state.settings.appName
  }
}

/**
 * Actions
 * 画面から呼ばれ、Mutationをコミットします
 */
const actions = {
  /** 設定を保存します */
  saveSettings ({ commit }, { settings }) {
    commit('saveSettings', { settings })
  },

  /** 設定を読み込みます */
  loadSettings ({ commit }) {
    commit('loadSettings')
  }
}

/** カンマ区切りの文字をトリミングして配列にします */
const createItems = v => v.split(',').map(v => v.trim()).filter(v => v.length !== 0)

/**
 * Getters
 * 画面から取得され、Stateを加工して渡します
 */
const getters = {
  /** 収入カテゴリ(配列) */
  incomeItems (state) {
    return createItems(state.settings.strIncomeItems)
  },
  /** 支出カテゴリ(配列) */
  outgoItems (state) {
    return createItems(state.settings.strOutgoItems)
  },
  /** タグ(配列) */
  tagItems (state) {
    return createItems(state.settings.strTagItems)
  }
}

const store = new Vuex.Store({
  state,
  mutations,
  actions,
  getters
})

export default store

突然 Mutations, Getters が現れました。
こちらも公式ドキュメント画像の引用になりますが、
Vuex では「Actions」→「Mutations」→「State」という流れで状態を更新します。

State は Mutations からしか変更しないようにします

Getters はコメントにもありますが、State を加工して渡します。
Vuex 版 computed のようなものです。

次に、設定画面で Vuex を使って設定保存できるようにします。

Settings.vue
<script>
export default {
  name: 'Settings',

  data () {
    /** ~ 省略 ~ */

    return {
      /** ~ 省略 ~ */

      /** 設定 */
      settings: { ...this.$store.state.settings },

      /** ~ 省略 ~ */
    }
  },

  methods: {
    /** 保存ボタンがクリックされたとき */
    onClickSave () {
      this.$store.dispatch('saveSettings', { settings: this.settings })
    }
  }
}
</script>

各コンポーネントでストアには $store でアクセスでき、
ストアから state や getters にアクセスできます。

// Stateのsettingsにアクセス
this.$store.state.settings

フォームの内容を書き換えるのと同時に State も書き換わるは困るので、
一度 settings の内容をコピーして使用するようにしています。

/** 設定 */
settings: { ...this.$store.state.settings }

Actions は dispatch メソッドで実行できます。

// dispatch('Action名', ペイロード)
this.$store.dispatch('saveSettings', { settings: this.settings })

// 以下の形式でもOKです
this.$store.dispatch({
  type: 'saveSettings',
  settings: this.settings
})

最後に、アプリ起動時に localStorage から読み込む処理を追加します。
ついでにアプリ名を反映させます。

App.vue
<template>
  <v-app>
    <!-- ツールバー -->
    <v-app-bar app color="green" dark>
      <!-- タイトル -->
      <v-toolbar-title>{{ appName }}</v-toolbar-title>
      <!-- ~ 省略 ~ -->
    </v-app-bar>
    <!-- ~ 省略 ~ -->
  </v-app>
</template>
<script>
import { mapState } from 'vuex'

export default {
  name: 'App',

  computed: mapState({
    appName: state => state.settings.appName
  }),

  // Appインスタンス生成前に一度だけ実行されます
  beforeCreate () {
    this.$store.dispatch('loadSettings')
  }
}
</script>

beforeCreate() の中で loadSettings を呼び出すようにしました。

mapState を使うと、State のアクセスを簡潔にできます。
色々な書き方があるのでこちらも参考にしてみてください。

// mapState を使わないと…
this.$store.state.settings.appName // 長い

// mapState を使うと…
this.appName // 短い!

現時点のソースコード一覧はこちらから確認できます!

家計簿アプリの動作を実装してみる

それでは、フロント実装最後の仕上げに入っていきます!✨
家計簿データを追加/編集/削除できるようにします。

Vuex ストア実装

家計簿のデータは State に保存します。
データは月ごとに管理したいので、以下のような構造で持つようにします。

// 家計簿データ(abData)の構造
{
  '2020-06': [
    { id: 'xxx', title: 'xxx',},
    { id: 'yyy', title: 'yyy',},
  ],
  '2020-07': [
    { id: 'zzz', title: 'zzz',}
  ],}

それでは、家計簿データの Action, Mutation を実装します。

store/index.js
/** ~ 省略 ~ */

/** 
 * State
 * Vuexの状態
 */
const state = {
  /** 家計簿データ */
  abData: {},

  /** ~ 省略 ~ */
}

/**
 * Mutations
 * ActionsからStateを更新するときに呼ばれます
 */
const mutations = {
  /** 指定年月の家計簿データをセットします */
  setAbData (state, { yearMonth, list }) {
    state.abData[yearMonth] = list
  },

  /** データを追加します */
  addAbData (state, { item }) {
    const yearMonth = item.date.slice(0, 7)
    const list = state.abData[yearMonth]
    if (list) {
      list.push(item)
    }
  },

  /** 指定年月のデータを更新します */
  updateAbData (state, { yearMonth, item }) {
    const list = state.abData[yearMonth]
    if (list) {
      const index = list.findIndex(v => v.id === item.id)
      list.splice(index, 1, item)
    }
  },

  /** 指定年月&IDのデータを削除します */
  deleteAbData (state, { yearMonth, id }) {
    const list = state.abData[yearMonth]
    if (list) {
      const index = list.findIndex(v => v.id === id)
      list.splice(index, 1)
    }
  },

  /** ~ 省略 ~ */
}

/**
 * Actions
 * 画面から呼ばれ、Mutationをコミットします
 */
const actions = {
  /** 指定年月の家計簿データを取得します */
  fetchAbData ({ commit }, { yearMonth }) {
    // サンプルデータを初期値として入れる
    const list = [
      { id: 'a34109ed', date: `${yearMonth}-01`, title: '支出サンプル', category: '買い物', tags: 'タグ1', income: null, outgo: 2000, memo: 'メモ' },
      { id: '7c8fa764', date: `${yearMonth}-02`, title: '収入サンプル', category: '給料', tags:'タグ1,タグ2', income: 2000, outgo: null, memo: 'メモ' }
    ]
    commit('setAbData', { yearMonth, list })
  },

  /** データを追加します */
  addAbData ({ commit }, { item }) {
    commit('addAbData', { item })
  },

  /** データを更新します */
  updateAbData ({ commit }, { beforeYM, item }) {
    const yearMonth = item.date.slice(0, 7)
    if (yearMonth === beforeYM) {
      commit('updateAbData', { yearMonth, item })
      return
    }
    const id = item.id
    commit('deleteAbData', { yearMonth: beforeYM, id })
    commit('addAbData', { item })
  },

  /** データを削除します */
  deleteAbData ({ commit }, { item }) {
    const yearMonth = item.date.slice(0, 7)
    const id = item.id
    commit('deleteAbData', { yearMonth, id })
  },

  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */

家計簿データを取得/追加/更新/削除する処理を追加しました。
どの処理も API 完成後に通信させます。

今回の実装内容は家計簿データの操作なので、複雑な処理はありませんが、
更新だけ少し特殊なので補足します。

// (Actions)
/** データを更新します */
updateAbData ({ commit }, { beforeYM, item }) {
  const yearMonth = item.date.slice(0, 7)
  // 更新前後で年月の変更が無ければそのまま値を更新
  if (yearMonth === beforeYM) {
    commit('updateAbData', { yearMonth, item })
    return
  }
  // 更新があれば、更新前年月のデータから削除して、新しくデータ追加する
  const id = item.id
  commit('deleteAbData', { yearMonth: beforeYM, id })
  commit('addAbData', { item })
},

ホーム画面からストアを呼び出す

Home.vue
<template>
  <div>
    <v-card>
      <v-card-title>
        <!-- 月選択 -->
        <v-col cols="8">
          <v-menu 
             省略 
          >
            <!-- ~ 省略 ~ -->
            <v-date-picker
               省略 
            >
              <v-spacer/>
              <v-btn text color="grey" @click="menu = false">キャンセル</v-btn>
              <v-btn text color="primary" @click="onSelectMonth">選択</v-btn>
            </v-date-picker>
          </v-menu>
        </v-col>
        <!-- ~ 省略 ~ -->
        </v-col>
      </v-card-title>
      <!-- ~ 省略 ~ -->
    </v-card>
    <!-- ~ 省略 ~ -->
  </div>
</template>
<script>
import { mapState, mapActions } from 'vuex'

/** ~ 省略 ~ */

export default {
  /** ~ 省略 ~ */

  data () {
    /** ~ 省略 ~ */

    return {
      /** ~ 省略 ~ */

      /** テーブルに表示させるデータ */
      tableData: []
    }
  },

  computed: {
    ...mapState({
      /** 家計簿データ */
      abData: state => state.abData
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    ...mapActions([
      /** 家計簿データを取得 */
      'fetchAbData'
    ]),

    /** 表示させるデータを更新します */
    updateTable () {
      const yearMonth = this.yearMonth
      const list = this.abData[yearMonth]

      if (list) {
        this.tableData = list
      } else {
        this.fetchAbData({ yearMonth })
        this.tableData = this.abData[yearMonth]
      }
    },

    /** 月選択ボタンがクリックされたとき */
    onSelectMonth () {
      this.$refs.menu.save(this.yearMonth)
      this.updateTable()
    },

    /** ~ 省略 ~ */
  },

  created () {
    this.updateTable()
  }
}
</script>

mapState は App.vue で利用しましたが、
それ以外にも mapActions, mapGetters などが用意されています。
スプレッド構文を使うといい感じに利用できます。

methods: {
  ...mapActions([
    /** 家計簿データを取得 */
    /**
     * this.$store.dispatch('fetchAbData') を
     * this.fetchAbData として使えるようにする
     */
    'fetchAbData'
  ]),}

追加/編集ダイアログからストアを呼び出す

収支カテゴリ設定などを State から取得するのと、
フォームに入力されたデータで追加/更新できるようにします。

ItemDialog.vue
<script>
import { mapActions, mapGetters } from 'vuex'

export default {
  name: 'ItemDialog',

  data () {
    return {
      /** ~ 省略 ~ */
      /** メモ */
      memo: '',

      /** 選択可能カテゴリ一覧 */
      categoryItems: [],
      /** 編集前の年月(編集時に使う) */
      beforeYM: '',

      /** ~ 省略 ~ */
    }
  },

  computed: {
    ...mapGetters([
      /** 収支カテゴリ */
      'incomeItems',
      'outgoItems',
      /** タグ */
      'tagItems'
    ]),

    /** ~ 省略 ~ */
  },

  methods: {
    ...mapActions([
      /** データ追加 */
      'addAbData',
      /** データ更新 */
      'updateAbData'
    ]),

    /** ~ 省略 ~ */

    /** 追加/更新がクリックされたとき */
    onClickAction () {
      const item = {
        date: this.date,
        title: this.title,
        category: this.category,
        tags: this.tags.join(','),
        memo: this.memo,
        income: null,
        outgo: null
      }
      item[this.inout] = this.amount || 0

      if (this.actionType === 'add') {
        item.id = Math.random().toString(36).slice(-8) // ランダムな8文字のIDを生成
        this.addAbData({ item })
      } else {
        item.id = this.id
        this.updateAbData({ beforeYM: this.beforeYM, item })
      }

      this.show = false
    },
    /** ~ 省略 ~ */
  }
}
</script>

ダイアログからデータの追加/編集ができるか確認してみてください!

削除ダイアログからストアを呼び出す

DeleteDialog.vue
<script>
import { mapActions } from 'vuex'

export default {
  name: 'DeleteDialog',

  /** ~ 省略 ~ */

  methods: {
    ...mapActions([
      /** データ削除 */
      'deleteAbData'
    ]),

    /** ~ 省略 ~ */

    /** 削除がクリックされたとき */
    onClickDelete () {
      this.deleteAbData({ item: this.item })
      this.show = false
    }
  }
}
</script>

ダイアログからデータの削除ができるか確認してみてください!

「Vue.js / Vue Router / Vuex でフロント実装してみる」は以上になります。
お疲れ様でした!🎉🍻

現時点のソースコード一覧はこちらから確認できます!