LoginSignup
381
473

More than 3 years have passed since last update.

【GAS x Vue.js】JavaScript のみで今、家計簿をつくるとしたら【ハンズオン付き!】

Last updated at Posted at 2020-07-05

「JavaScriptのみ」&「無料」&「サーバーレス」なスプレッドシートと連携した家計簿をつくる方法を考えてみました。
実際に家計簿アプリを作るハンズオン付きです!

※こちらの記事は一部古い内容となっております。
Zennに投稿している本を更新していますので、よければこちらをご覧ください。
https://zenn.dev/matsu7089/books/gas-account-book

なにを作ったの?

Web上でデータを登録すると、スプレッドシートに反映される家計簿アプリです。
実際のページはこちら。使い方は「家計簿アプリお試し方法」で説明します。

データ追加の他に、データ編集と

データ削除を行えます。

スプレッドシートは月ごとにシートで管理され、Webアプリと同じように収支の合計も確認できます。

また、カテゴリ別の支出も確認できます。

使用した技術

  • バックエンド
    • Google Apps Script (GAS)
  • フロントエンド
    • Vue.js / Vue Router / Vuex
    • Vuetify
    • axios

全体の構成はこんなイメージです。シンプル。

制作のポイント

GAS で REST API もどきを作った

GAS で受け付けることのできるリクエストは GETPOST の2種類だけです。(doGet, doPost 関数)
これでは REST API を作ることはできないので、
リクエスト内容にメソッドの文字列を入れることで擬似的に GET, POST, PUT, DELETE に対応させました!:v:

家計簿は月ごとにシートを分けた

:o: メリット

  • 指定年月のデータ取得時の実行コストが低くなる
  • データ数が増えても API が重くなりにくい
  • スプレッドシートの内容を確認しやすい

指定年月のシートのデータをすべて取得すればいいので、「データが指定年月のものであるか?」を確認する必要がなくなります。
そのため、データ数が多くなっても1枚のシートで管理するより重くなりにくいです。:muscle:

また、Webアプリ/スプレッドシートどちらからでも家計簿のデータを確認しやすいのが強みです。

:x: デメリット

  • データ年月の編集時の実行コストが高くなる
  • 月をまたいだデータの取得/集計などが困難になる

編集前後でデータの年月を変えると、
「編集前の年月シートから削除」→「編集後の年月シートに追加」
する必要があるので、コストが高くなってしまいます。(そんな編集をすることは滅多にないと思いますが…)

また、今回作った API の仕様だと、1年分のデータを取得するのに、12回 API を叩く必要があります。
Webアプリでは月ごとの表示しかしていませんが、より細かい集計などするには API の改修が必要そうです。:innocent:

家計簿アプリお試し方法

それでは、実際にこのアプリを試してみる方法を紹介します。
3ステップだけで完了します!:sparkles:

STEP 1:シート準備

Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディタ」をクリックします。

↓が表示されていることを確認します。

もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にします。

コード.gsこのプログラムをコピペして保存します。プロジェクト名は好きな名前でOKです。

STEP 2:API URL の発行

「公開」タブ→「ウェブ アプリケーションとして導入」をクリックします。

「Project version」は「New」、
「Execute the app as」は「Me (自分のメールアドレス)」、
「Who has access to the app」は「Anyone, even anonymous」
で「Deploy」ボタンをクリックします。

「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。

「Deploy as web app」というダイアログが表示されれば、準備完了です。
「Current web app URL」の内容をコピーしておきます。
※実際の URL はもっと長いです。

この URL は誰でもアクセスができてしまうので、一応 authToken を設定できます。(URL を他人に知られることはないと思いますが)

※この設定は任意です
「ファイル」タブ→「プロジェクトのプロパティ」→「スクリプトのプロパティ」を開きます。
authToken という行を追加して、UUID v4 などの値を設定します。

STEP 3:アプリ設定

家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
※ STEP 2 で authToken を設定してない方は空のままでOKです。

右上にあるシートマークのボタンをクリックします。
↓このマーク

エラーが表示されなければ準備完了です!
実際に家計簿データを入力して、スプレッドシートに反映されるか試してみてください!

アプリを作ってみる!

おまたせしました!ここからハンズオンになります!
対象は JavaScript / Vue.js 初心者~中級者向けです。

ハンズオンは以下の3部構成でお送りします!

内容結構長いので、記事の最後まで飛びたい方はこちらをクリック。

環境構築

開発環境

Node.jsYarn がインストールされている前提で進めます。
下記のバージョンと近いものか、高いものであれば基本動くと思います。

> node -v
v12.16.3

> yarn -v
v1.22.4

Vue CLI 4 のインストール

Vue.js アプリを簡単につくることができるようになる Vue CLI をインストールします。
執筆時点の最新バージョンは 4.4.5 でした。

> yarn global add @vue/cli

> vue --version
@vue/cli 4.4.5

プロジェクトの作成

vue create 好きなアプリ名 と打つと、プロジェクトを作成できます。
実行例では、アプリ名を gas-account-book として進めます。

> vue create gas-account-book

デフォルトを選択すると一発でプロジェクトを作成できますが、
今回 Vue RouterVuex を追加したいので、マニュアルで進めます。
(上下でカーソル移動、エンターで決定できます)

Vue CLI v4.4.5
? Please pick a preset:    
  default (babel, eslint)  
> Manually select features

「Babel」「Linter / Formatter」の選択はそのままで、
「Router」「Vuex」を追加して決定します。
(スペースキーで選択の状態を切り替えられます)

? Check the features needed for your project: 
>(*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support        
 (*) Router
 (*) Vuex
 ( ) CSS Pre-processors
 (*) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing

history mode を使うか?と尋ねられますが、今回は使わないので「n」を入力します。

? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) n

ESLint の設定はエラー防止のみの「ESLint with error prevention only」を選択します。

? Pick a linter / formatter config: 
> ESLint with error prevention only
  ESLint + Airbnb config
  ESLint + Standard config
  ESLint + Prettier

保存のときに Lint してもらいたいので、「Lint on save」のまま次へ。

? Pick additional lint features:
>(*) Lint on save
 ( ) Lint and fix on commit

設定ファイルは config ファイルに書いてほしいので、
「In dedicated config files」を選択。

? Where do you prefer placing config for Babel, ESLint, etc.? 
> In dedicated config files
  In package.json

今回のプロジェクト設定を保存するか聞かれますが、「N」で次へ。

? Save this as a preset for future projects? (y/N) N

必要パッケージのインストールがはじまります。

Vue CLI v4.4.5
✨  Creating project in /xxxxx/gas-account-book.
🗃️  Initializing git repository...
⚙️  Installing CLI plugins. This might take a while...

このように表示されれば完了です。

🎉  Successfully created project gas-account-book.
👉  Get started with the following commands:

 $ cd gas-account-book
 $ yarn serve

gas-account-book ディレクトリ内に移動します。

> cd gas-account-book

次に Vuetify を追加します。
Vue CLI を使うと、簡単にプラグインもインストールできます!

ちなみに Vuetify とは Vue 用のマテリアルデザインフレームワークです。
今回はデザインを Vuetify まかせにしてサボります。

このハンズオンに出てくる v- から始まるタグはすべて Vuetify のコンポーネントです。
デザイン面の話はあまり触れないので、気になる方は公式ドキュメントを参照してください。

> vue add vuetify

この設定はデフォルトで進めます。

✔  Successfully installed plugin: vue-cli-plugin-vuetify

? Choose a preset:
> Default (recommended)
  Prototype (rapid development)
  Configure (advanced)

このように表示されれば完了です。

✔  Successfully invoked generator for plugin: vue-cli-plugin-vuetify
 vuetify  Discord community: https://community.vuetifyjs.com
 vuetify  Github: https://github.com/vuetifyjs/vuetify
 vuetify  Support Vuetify: https://github.com/sponsors/johnleider

yarn serve コマンドで開発サーバーを起動してみます。

> yarn serve

localhost:8080 にブラウザーでアクセスして、
「Welcome to Vuetify」が表示されれば環境構築完了です!:sparkles:

この開発サーバーでは ホットリロード が有効なので、ファイル編集がすぐに反映されます。
以降はこのサーバーが起動している前提で進めて行きます。

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

Vue.js / Vue Router / Vuex でフロント実装してみる

ようやく環境構築が終わりました。
はじめに、ディレクトリ構成について軽く把握しておきましょう。
ざっとこんな感じになっています。

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>

上部に緑色のツールバーが表示されました。
toolbar-min.png

ツールバーに表示されたボタンを押すと画面が切り替わると思います。
これは、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です。

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

検索フォームでは 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>
<!-- ~ 省略 ~ -->
views/Home.vue
/** ~ 省略 ~ */
<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 内の templatev-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 を作成します。
コードは少なめです:smile:

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 でアクセスでき、
ストアから stategetters にアクセスできます。

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

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

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

Actionsdispatch メソッドで実行できます。

// 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 // 短い

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

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

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

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 でフロント実装してみる」は以上になります。
お疲れ様でした!:tada: :beer:

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

Google Apps Script で REST API もどきを作ってみる

こちらから GAS で API の作成になります!!

「制作のポイント」でも触れましたが、擬似的にメソッドを指定して
GET で取得、POST で追加、PUT で更新、DELETE で削除できる API を作成します。

シート準備

まずはじめにシートの準備をします。
Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディター」をクリックします。

↓が表示されていることを確認します。

もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にしてください。

プロジェクトの名前を「家計簿API」と保存して、コード.gsapi.gs にリネームします。
api.gs の内容を書き換えます。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  console.log(ss.getName())
}

メニューで「test」が選択されていることを確認してから
:arrow_forward: ボタンをクリックします。

「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。

Ctrl + Enter (mac は Command + Enter) でログを確認できます。
作成したシートの名前が表示されればOKです。

家計簿のテンプレートをつくる

まずはじめに、家計簿のテンプレートとなるシートを作成する関数 insertTemplate を作ります。
シートのイメージを大雑把にまとめると

A1:B4 に収支確認エリア

A6:H6 にテーブルのヘッダー

J1:L1 にカテゴリ別支出のヘッダー

です。これをプログラムに落とし込みます。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  insertTemplate('2020-06')
}

/**
 * 指定年月のテンプレートシートを作成します
 * @param {String} yearMonth
 * @returns {Sheet} sheet
 */
function insertTemplate (yearMonth) {
  const { SOLID_MEDIUM, DOUBLE } = SpreadsheetApp.BorderStyle

  const sheet = ss.insertSheet(yearMonth, 0)
  const [year, month] = yearMonth.split('-')

  // 収支確認エリア
  sheet.getRange('A1:B1')
    .merge()
    .setValue(`${year}${parseInt(month)}月`)
    .setFontWeight('bold')
    .setHorizontalAlignment('center')
    .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM)

  sheet.getRange('A2:A4')
    .setValues([['収入:'], ['支出:'], ['収支差:']])
    .setFontWeight('bold')
    .setHorizontalAlignment('right')

  sheet.getRange('B2:B4')
    .setFormulas([['=SUM(F7:F)'], ['=SUM(G7:G)'], ['=B2-B3']])
    .setNumberFormat('#,##0')

  sheet.getRange('A4:B4')
    .setBorder(true, null, null, null, null, null, 'black', DOUBLE)

  // テーブルヘッダー
  sheet.getRange('A6:H6')
    .setValues([['id', '日付', 'タイトル', 'カテゴリ', 'タグ', '収入', '支出', 'メモ']])
    .setFontWeight('bold')
    .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM)

  sheet.getRange('F7:G')
    .setNumberFormat('#,##0')

  // カテゴリ別支出
  sheet.getRange('J1')
    .setFormula('=QUERY(B7:H, "select D, sum(G), sum(G) / "&B3&"  where G > 0 group by D order by sum(G) desc label D \'カテゴリ\', sum(G) \'支出\'")')

  sheet.getRange('J1:L1')
    .setFontWeight('bold')
    .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM)

  sheet.getRange('L1')
    .setFontColor('white')

  sheet.getRange('K2:K')
    .setNumberFormat('#,##0')

  sheet.getRange('L2:L')
    .setNumberFormat('0.0%')

  sheet.setColumnWidth(9, 21)

  return sheet
}

スプレッドシートは SpreadsheetApp を利用して取得します。
取得の方法は2つあります。

  • スプレッドシートIDを指定する openById(id)
  • 紐付いているスプレッドシートを取得する getActive()

今回はスプレッドシートと紐付いている GAS プロジェクトを作成したので、後者で取得します。

const ss = SpreadsheetApp.getActive()

新規シートを作成するときには insertSheet メソッドを使います。
引数にシート名とインデックスを指定します。インデックスは 0 で一番左に追加されます。
返り値は新規作成したシートです。

const sheet = ss.insertSheet('シート名', インデックス)

セル操作の流れは、範囲(Range)を取得してから各操作を実行します。
シートの getRange メソッドで範囲を取得できます。
A1 形式のほうが(個人的に)見やすいので、今回のプログラムではこちらに統一します。

ex.
/** 単一のセルを取得する */
// getRange(行, 列)
sheet.getRange(1, 2) // B1
// getRange(A1形式)
sheet.getRange('B1') // B1

/** 複数のセルを取得する */
// getRange(開始行, 開始列, 何行分選択するか, 何列分選択するか)
sheet.getRange(1, 2, 3, 4) // B1:E3
// getRange(A1形式)
sheet.getRange('B1:E3')    // B1:E3

各セル操作は Range を返すので、メソッドチェーンを利用できます。
可能な操作はすべて公式リファレンスに記載されているので、こちらも確認してみてください。

ex.メソッドチェーン
sheet.getRange('A1')
  .func1() // どの操作も
  .func2() // A1に対して
  .func3() // 実行される

セル操作については重要な setValue, setValues メソッドを説明します。
単一セルの値をセットするときは setValue
複数セルの値をセットするときは setValues を使います。

setValues では必ず2次元配列を渡します。改行してみると分かりやすいです。

ex.
// A1に"A1 value"をセット
sheet.getRange('A1')
  .setValue('A1 value')

// 複数セルの値をセットするときは
// 2次元配列を渡します
sheet.getRange('A1:B2')
  .setValues([
    ['A1', 'B1'],
    ['A2', 'B2']
  ])

// 1行(1列)だけでも2次元配列を渡します
sheet.getRange('A6:H6')
  .setValues([
    ['id', '日付', 'タイトル', 'カテゴリ', 'タグ', '収入', '支出', 'メモ']
  ])

また、= から始まる数式をセットしたい場合は、
setFormula, setFormulas メソッドを使います。

ex.
sheet.getRange('A1')
  .setFormula('=PI()')

sheet.getRange('B2:B4')
  .setFormulas([
    ['=SUM(F7:F)'],
    ['=SUM(G7:G)'],
    ['=B2-B3']
  ])

この状態で test を実行してみます。
2020-06 というシートが新しく作成され、テンプレートが書き込まれることを確認してください!

データを追加する onPost をつくる

それでは API のプログラム作成に入ります!
API は成功時には何かしらの結果を返し、エラー時には { error: 'メッセージ' } を返す仕様にします。

まずはデータの追加です。onPost と、
一応入力データのバリデーションを行う isValid を作成します。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  onPost({
    item: {
      date: '2020-07-01',
      title: '支出サンプル',
      category: '食費',
      tags: 'タグ1,タグ2',
      income: null,
      outgo: 3000,
      memo: 'メモメモ'  
    }
  })
}

/** --- API --- */

/**
 * データを追加します
 * @param {Object} params
 * @param {Object} params.item 家計簿データ
 * @returns {Object} 追加した家計簿データ
 */
function onPost ({ item }) {
  if (!isValid(item)) {
    return {
      error: '正しい形式で入力してください'
    }
  }
  const { date, title, category, tags, income, outgo, memo } = item

  const yearMonth = date.slice(0, 7)
  const sheet = ss.getSheetByName(yearMonth) || insertTemplate(yearMonth)

  const id = Utilities.getUuid().slice(0, 8)
  const row = ["'" + id, "'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]
  sheet.appendRow(row)

  return { id, date, title, category, tags, income, outgo, memo }
}

/** --- common --- */

/**
 * 指定年月のテンプレートシートを作成します
 * @param {String} yearMonth
 * @returns {Sheet} sheet
 */
function insertTemplate (yearMonth) {
  /** ~ 省略 ~ */
}

/**
 * データが正しい形式か検証します
 * @param {Object} item
 * @returns {Boolean} isValid
 */
function isValid (item = {}) {
  const strKeys = ['date', 'title', 'category', 'tags', 'memo']
  const keys = [...strKeys, 'income', 'outgo']

  // すべてのキーが存在するか
  for (const key of keys) {
    if (item[key] === undefined) return false
  }

  // 収支以外が文字列であるか
  for (const key of strKeys) {
    if (typeof item[key] !== 'string') return false
  }

  // 日付が正しい形式であるか
  const dateReg = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/
  if (!dateReg.test(item.date)) return false

  // 収支のどちらかが入力されているか
  const { income: i, outgo: o } = item
  if ((i === null && o === null) || (i !== null && o !== null)) return false

  // 入力された収支が数字であるか
  if (i !== null && typeof i !== 'number') return false
  if (o !== null && typeof o !== 'number') return false

  return true
}

シートの取得は getSheetByName でシート名を指定して取得します。
シートがなかった場合は null が返ってくるので、insertTemplate が実行されます。

// 指定年月シートを取得する、なかったらテンプレートシートを作成する
const sheet = ss.getSheetByName(yearMonth) || insertTemplate(yearMonth)

また、シートには appendRow というシンプルで便利なメソッドが用意されているので、
引数に配列を渡すだけで簡単にデータの追加をできます。

収支以外は文字列として扱ってほしいので、値の前にシングルクォートを付与してからシートに追加します。
値をセットするとき、文字列を渡しても数字や日付などは自動で変換されるので注意が必要です。

ex.
const a1 = sheet.getRange('A1').setValue("100").getValue()
const b1 = sheet.getRange('B1').setValue("'100").getValue()

console.log(typeof a1) // -> "number"
console.log(typeof b1) // -> "string"

ID は UtilitiesgetUuid を利用して UUID の先頭8文字だけ切り取るという謎のプログラムで生成しています。
公式リファレンスで使える便利メソッドが記載されているので、ぜひ確認してみてください。

const id = Utilities.getUuid().slice(0, 8)

この状態で test を実行してみます。
シートが新しく作成され、データの追加を確認してください!

データ取得する onGet をつくる

追加ができたら、次は取得してみたいですね。onGet を作ります。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  const result = onGet({ yearMonth: '2020-07' })
  console.log(result)
}

/** --- API --- */

/**
 * 指定年月のデータ一覧を取得します
 * @param {Object} params
 * @param {String} params.yearMonth 年月
 * @returns {Object[]} 家計簿データ
 */
function onGet ({ yearMonth }) {
  const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/

  if (!ymReg.test(yearMonth)) {
    return {
      error: '正しい形式で入力してください'
    }
  }

  const sheet = ss.getSheetByName(yearMonth)
  const lastRow = sheet ? sheet.getLastRow() : 0

  if (lastRow < 7) {
    return []
  }

  const list = sheet.getRange('A7:H' + lastRow).getValues().map(row => {
    const [id, date, title, category, tags, income, outgo, memo] = row
    return {
      id,
      date,
      title,
      category,
      tags,
      income: (income === '') ? null : income,
      outgo: (outgo === '') ? null : outgo,
      memo
    }
  })

  return list
}

/** ~ 省略 ~ */

テーブルのヘッダーが A6:H6 にあるので、A7:H{最終行} のデータを取得します。

シートの最終行は getLastRow で取得できます。
指定年月のシートが存在しない場合も考慮して、最終行が7未満の場合は空の配列を返します。

const sheet = ss.getSheetByName(yearMonth)
const lastRow = sheet ? sheet.getLastRow() : 0

if (lastRow < 7) {
  return []
}

データを返すときはオブジェクトにして返したいので、
getValues で受け取った2次元配列を map でオブジェクトに加工します。

空白セルは空文字('')として取得されるので、収支だけ注意が必要です。

ex.
const values = [
  ['xxx', '2020-07-01', 'sample1'],
  ['yyy', '2020-07-02', 'sample2']
]

const list = values.map(row => {
  return {
    id: row[0],
    date: row[1],
    title: row[2]
  }
})

console.log(list)
// -> [
//      { id: "xxx", date: "2020-07-01", title: "sample1" },
//      { id: "yyy", date: "2020-07-02", title: "sample2" }
//    ]

この状態で test を実行してみます。
追加したデータがオブジェクトの配列で返ってくることを確認してください!

データ削除する onDelete をつくる

機能はあと2つです! onDelete を作ります。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  const result = onDelete({ yearMonth: '2020-07', id: 'xxxxxxxx' })
  console.log(result)
}

/** --- API --- */

function onGet ({ yearMonth }) {
  /** ~ 省略 ~ */
}

function onPost ({ item }) {
  /** ~ 省略 ~ */
}

/**
 * 指定年月&idのデータを削除します
 * @param {Object} params
 * @param {String} params.yearMonth 年月
 * @param {String} params.id id
 * @returns {Object} メッセージ
 */
function onDelete ({ yearMonth, id }) {
  const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/
  const sheet = ss.getSheetByName(yearMonth)

  if (!ymReg.test(yearMonth) || sheet === null) {
    return {
      error: '指定のシートは存在しません'
    }
  }

  const lastRow = sheet.getLastRow()
  const index = sheet.getRange('A7:A' + lastRow).getValues().flat().findIndex(v => v === id)

  if (index === -1) {
    return {
      error: '指定のデータは存在しません'
    }
  }

  sheet.deleteRow(index + 7)
  return {
    message: '削除完了しました'
  }
}

/** ~ 省略 ~ */

内容はシンプルです。指定年月&id のデータが存在したら deleteRow で行を削除するだけです。
A7:A{最終行} で範囲の値を取得すると、2次元配列になっているのでフラットにしてから id を探します。

ex.
const values = [['xxx'], ['yyy'], ['zzz']]
const flatted = values.flat()
console.log(flatted) // -> ['xxx', 'yyy', 'zzz']
console.log(flatted.findIndex(v => v === 'yyy')) // -> 1

インデックスが見つかれば、インデックスに7行分足した行を削除するだけです。

sheet.deleteRow(index + 7)

この状態で test の指定年月&id を書き換えて実行してみます。
指定のデータが削除され、「削除完了しました」というメッセージをログで確認してください!

データ更新する onPut をつくる

最後の機能です! onPut を作ります。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  onPut({
    beforeYM: '2020-07',
    item: {
      id: 'xxxxxxxx',
      date: '2020-07-31',
      title: '更新サンプル',
      category: '食費',
      tags: 'タグ1,タグ2',
      income: null,
      outgo: 5000,
      memo: '更新したよ'  
    }
  })
}

/** --- API --- */

function onGet ({ yearMonth }) {
  /** ~ 省略 ~ */
}

function onPost ({ item }) {
  /** ~ 省略 ~ */
}

function onDelete ({ yearMonth, id }) {
  /** ~ 省略 ~ */
}

/**
 * 指定データを更新します
 * @param {Object} params
 * @param {String} params.beforeYM 更新前の年月
 * @param {Object} params.item 家計簿データ
 * @returns {Object} 更新後の家計簿データ
 */
function onPut ({ beforeYM, item }) {
  const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/
  if (!ymReg.test(beforeYM) || !isValid(item)) {
    return {
      error: '正しい形式で入力してください'
    }
  }

  // 更新前と後で年月が違う場合、データ削除と追加を実行
  const yearMonth = item.date.slice(0, 7)
  if (beforeYM !== yearMonth) {
    onDelete({ yearMonth: beforeYM, id: item.id })
    return onPost({ item })
  }

  const sheet = ss.getSheetByName(yearMonth)
  if (sheet === null) {
    return {
      error: '指定のシートは存在しません'
    }
  }

  const id = item.id
  const lastRow = sheet.getLastRow()
  const index = sheet.getRange('A7:A' + lastRow).getValues().flat().findIndex(v => v === id)

  if (index === -1) {
    return {
      error: '指定のデータは存在しません'
    }
  }

  const row = index + 7
  const { date, title, category, tags, income, outgo, memo } = item

  const values = [["'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]]
  sheet.getRange(`B${row}:H${row}`).setValues(values)

  return { id, date, title, category, tags, income, outgo, memo }
}

/** ~ 省略 ~ */

編集だけ「更新前と後で年月が違う場合」を考慮しないといけません。
削除と追加の処理は onDeleteonPost に任せます。

// 更新前と後で年月が違う場合、データ削除と追加を実行
const yearMonth = item.date.slice(0, 7)
if (beforeYM !== yearMonth) {
  onDelete({ yearMonth: beforeYM, id: item.id })
  return onPost({ item })
}

同じシートで完結できる場合は id 列以外の B?:H?setValues で更新します。
編集する行はデータ削除の時と同じように探します。

const values = [["'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]]
sheet.getRange(`B${row}:H${row}`).setValues(values)

この状態で test の編集前年月と item の id を書き換えて実行してみます。
id 列以外のデータが更新されることを確認してください!

リクエストを受け取れるようにする

機能がすべて揃ったので、GAS 側でリクエストを受け取れるようにします。
GAS では doGet, doPost という関数を作ると、GET, POST を受け取ることができます。

この画像3回目の登場になりますが、doPost で受け取り、
onGet, onPost, onPut, onDelete に振り分ける処理を追加します。

const ss = SpreadsheetApp.getActive()
const authToken = PropertiesService.getScriptProperties().getProperty('authToken') || ''

/**
 * レスポンスを作成して返します
 * @param {*} content
 * @returns {TextOutput}
 */
function response (content) {
  const res = ContentService.createTextOutput()
  res.setMimeType(ContentService.MimeType.JSON)
  res.setContent(JSON.stringify(content))
  return res
}

/**
 * アプリにPOSTリクエストが送信されたとき実行されます
 * @param {Event} e
 * @returns {TextOutput}
 */
function doPost (e) {
  let contents
  try {
    contents = JSON.parse(e.postData.contents)
  } catch (e) {
    return response({ error: 'JSONの形式が正しくありません' })
  }

  if (contents.authToken !== authToken) {
    return response({ error: '認証に失敗しました' })
  }

  const { method = '', params = {} } = contents

  let result
  try {
    switch (method) {
      case 'POST':
        result = onPost(params)
        break
      case 'GET':
        result = onGet(params)
        break
      case 'PUT':
        result = onPut(params)
        break
      case 'DELETE':
        result = onDelete(params)
        break
      default:
        result = { error: 'methodを指定してください' }
    }
  } catch (e) {
    result = { error: e }
  }

  return response(result)
}

/** --- API --- */

/** ~ 省略 ~ */

GAS でレスポンスを返すときは ContentService を利用します。
作成した API では JSON しか返さないので mime type には MimeType.JSON を指定します。

function response (content) {
  const res = ContentService.createTextOutput()
  // レスポンスの Content-Type ヘッダーに "application/json" を設定する
  res.setMimeType(ContentService.MimeType.JSON)
  // オブジェクトを文字列にしてからレスポンスに詰め込む
  res.setContent(JSON.stringify(content))
  return res
}

次に doPost の中をみていきます。
送られたリクエストは e.postData.contents で取得できます。
文字列なので JSON にパースします。一応 try catch で囲んでおきます。

let contents
try {
  contents = JSON.parse(e.postData.contents)
} catch (e) {
  return response({ error: 'JSONの形式が正しくありません' })
}

受け取るリクエストの内容はこのような形式としてます。

リクエストの構造
{
  method: 'GET or POST or PUT or DELETE',
  authToken: '認証情報',
  params: {
    // 任意の処理の引数となるデータ
  }
}

誰でもアクセス可能な URL を発行するので、認証情報 authToken を持っている人しかアクセスできないようにします。
認証情報はソースコードに書きたくないので、PropertiesService を利用してスクリプトのプロパティから取得します。

「ファイル」タブ→「プロジェクトのプロパティ」→「スクリプトのプロパティ」から設定できます。

const authToken = PropertiesService.getScriptProperties().getProperty('authToken') || ''

処理はシンプルに case 文で分けます。
実行中にエラー起きても大丈夫なように、一応 try catch で囲んでおきます。

let result
try {
  switch (method) {
    case 'POST':
      result = onPost(params)
      break
    case 'GET':
      result = onGet(params)
      break
    case 'PUT':
      result = onPut(params)
      break
    case 'DELETE':
      result = onDelete(params)
      break
    default:
      result = { error: 'methodを指定してください' }
  }
} catch (e) {
  result = { error: e }
}

最後に実行結果をレスポンスとして返します。

return response(result)

ついに API 完成です!! :sparkles: :sparkles:

API を叩いてみる

API URL を発行します。

「公開」タブ→「ウェブ アプリケーションとして導入」をクリックします。

「Project version」は「New」、
「Execute the app as」は「Me (自分のメールアドレス)」、
「Who has access to the app」は「Anyone, even anonymous」
で「Deploy」ボタンをクリックします。

「Deploy as web app」というダイアログが表示されれば、準備完了です。
「Current web app URL」の内容をコピーしておきます。
※実際の URL はもっと長いです。

curl などを使ってこの API を叩いてみます。
authToken や yearMonth の値は置き換えてください。

> curl -L -d "{\"method\":\"GET\",\"authToken\":\"\",\"params\":{\"yearMonth\":\"2020-07\"}}" https://script.google.com/macros/s/xxxxx/exec

[{"id":"5e30de41","date":"2020-07-31","title":"サンプル","category":"食費","tags":"タグ1,タグ2","income":null,"outgo":5000,"memo":"メモメモ"}]

データが正常に返ってくればOKです!

「Google Apps Script で REST API もどきを作ってみる」は以上になります。
お疲れ様でした!:tada: :beer:

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

API が叩かれたときのログを出力する

こちらを進めるのは任意になりますが、
API の実行ログを確認できるようにする方法を紹介します。

クリックで展開

最初に Ctrl + Enter (mac は Command + Enter) でログを確認できるということをお伝えしましたが、「Apps Script ダッシュボード」から過去のログを確認することもできます。

ログを表示させたあと、下に表示される「Apps Script ダッシュボードで、実行された他のスクリプトの Stackdriver ログを確認できます。」というメッセージからダッシュボードに移動できます。

以下の test を1度実行してからダッシュボードで確認すると、

function test () {
  console.log('log')
  console.info('info')
  console.warn('warn')
  console.error('error')
}

このようにログが表示されます。

しかし doPost を匿名で実行した場合は詳細が表示できないようです。
:arrow_down_small: マークが表示されず、バージョンやステータスしか確認できません。

なので、今回はシートにログを記録したいと思います。

log シートを作り、A1:C1 に「日付」「レベル」「メッセージ」を記入します。

api.gslog 関数を追加します。

一応ログは最大100件まで保存するようにしました。
logMaxRow を書き換えれば最大保存件数を変更できます。

api.gs
/** ~ 省略 ~ */

const logMaxRow = 101
const logSheet = ss.getSheetByName('log')

/**
 * ログをシートに記録します
 * @param {String} level 
 * @param {String} message 
 */
function log (level, message) {
  logSheet.appendRow([new Date(), level.toUpperCase(), message])

  if (logMaxRow < logSheet.getLastRow()) {
    logSheet.deleteRow(2)
  }
}

この状態で test の内容を書き換えて実行してみます。

function test () {
  log('info', 'info メッセージ')
  log('warn', 'warn メッセージ')
  log('error', 'error メッセージ')
}

log シートがこのように書き換わります。

あとは、API の好きな部分で log を実行するだけです!

シートに条件付き書式などを設定して、見やすくすると良さそうです。
スプレッドシートの「フィルタ表示」機能を使うとフィルタリングもできます!

ログを記録するサンプルコードはこちらから確認できます!

作った API と axios で実際に通信してみる

それではフロントと API を連携させて、家計簿を完成させていきます!

まずは、axios というライブラリをプロジェクトに追加します。
API にアクセスする際よく利用されます。

> yarn add axios

Vuex の中で axios を使って API にアクセスします。
この図の Actions <---> Backend API の部分を実装します。

API クライアントをつくる

src の中に新しく api ディレクトリを作成し、
その中に gasApi.js を作成します。

このリクエストを送れるようにします。

リクエストの構造
{
  method: 'GET or POST or PUT or DELETE',
  authToken: '認証情報',
  params: {
    // 任意の処理の引数となるデータ
  }
}
gasApi.js
import axios from 'axios'

// 共通のヘッダーを設定したaxiosのインスタンス作成
const gasApi = axios.create({
  headers: { 'content-type': 'application/x-www-form-urlencoded' }
})

// response共通処理
// errorが含まれていたらrejectする
gasApi.interceptors.response.use(res => {
  if (res.data.error) {
    return Promise.reject(res.data.error)
  }
  return Promise.resolve(res)
}, err => {
  return Promise.reject(err)
})

/**
 * APIのURLを設定します
 * @param {String} url
 */
const setUrl = url => {
  gasApi.defaults.baseURL = url
}

/**
 * authTokenを設定します
 * @param {String} token
 */
let authToken = ''
const setAuthToken = token => {
  authToken = token
}

/**
 * 指定年月のデータを取得します
 * @param {String} yearMonth
 * @returns {Promise}
 */
const fetch = yearMonth => {
  return gasApi.post('', {
    method: 'GET',
    authToken,
    params: {
      yearMonth
    }
  })
}

/**
 * データを追加します
 * @param {Object} item
 * @returns {Promise}
 */
const add = item => {
  return gasApi.post('', {
    method: 'POST',
    authToken,
    params: {
      item
    }
  })
}

/**
 * 指定年月&idのデータを削除します
 * @param {String} yearMonth
 * @param {String} id
 * @returns {Promise}
 */
const $delete = (yearMonth, id) => {
  return gasApi.post('', {
    method: 'DELETE',
    authToken,
    params: {
      yearMonth,
      id
    }
  })
}

/**
 * データを更新します
 * @param {String} beforeYM
 * @param {Object} item
 * @returns {Promise}
 */
const update = (beforeYM, item) => {
  return gasApi.post('', {
    method: 'PUT',
    authToken,
    params: {
      beforeYM,
      item
    }
  })
}

export default {
  setUrl,
  setAuthToken,
  fetch,
  add,
  delete: $delete,
  update
}

最初に共通の設定をしたインスタンスを作成します。あとからデフォルト設定を上書きもできます。

// 共通のヘッダーを設定したaxiosのインスタンス作成
const gasApi = axios.create({
  headers: { 'content-type': 'application/x-www-form-urlencoded' }
})

// リクエスト先のURLを変更する
gasApi.defaults.baseURL = 'https://xxxxx.com'

インスタンスを作成すると get, post, put, delete などのメソッドが使えます。
このメソッドで各リクエストを送信できます。今回は API の仕様上すべて post を使います。

gasApi.post(url, data)

また、interceptors を利用するとリクエスト時、レスポンス時の共通処理を設定できます。
今回はレスポンスの内容に error が含まれていた場合、reject してエラーにします。

// response共通処理
// errorが含まれていたらrejectする
gasApi.interceptors.response.use(res => {
  if (res.data.error) {
    return Promise.reject(res.data.error)
  }
  return Promise.resolve(res)
}, err => {
  return Promise.reject(err)
})

API からデータを取得する

それでは、作成した API クライアントを使用して実際に通信してみます。

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

Vue.use(Vuex)

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

  /** ローディング状態 */
  loading: {
    fetch: false,
    add: false,
    update: false,
    delete: false
  },

  /** エラーメッセージ */
  errorMessage: '',

  /** 設定 */
  settings: {
    /** ~ 省略 ~ */
  }
}

/**
 * Mutations
 * ActionsからStateを更新するときに呼ばれます
 */
const mutations = {
  /** ~ 省略 ~ */

  /** ローディング状態をセットします */
  setLoading (state, { type, v }) {
    state.loading[type] = v
  },

  /** エラーメッセージをセットします */
  setErrorMessage (state, { message }) {
    state.errorMessage = message
  },

  /** 設定を保存します */
  saveSettings (state, { settings }) {
    state.settings = { ...settings }
    const { appName, apiUrl, authToken } = state.settings
    document.title = appName
    gasApi.setUrl(apiUrl)
    gasApi.setAuthToken(authToken)
    // 家計簿データを初期化
    state.abData = {}

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

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

/**
 * Actions
 * 画面から呼ばれ、Mutationをコミットします
 */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    const type = 'fetch'
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.fetch(yearMonth)
      commit('setAbData', { yearMonth, list: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
      commit('setAbData', { yearMonth, list: [] })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}

/** ~ 省略 ~ */

import で作成したクライアントを使えるようにして、
state にローディング状態とエラーメッセージを追加します。

import gasApi from '../api/gasApi'
/** ローディング状態 */
loading: {
  fetch: false,
  add: false,
  update: false,
  delete: false
},

/** エラーメッセージ */
errorMessage: '',

saveSettings, loadSettings 内でアプリ設定の apiUrl, authToken を gasApi に反映させます。

const { appName, apiUrl, authToken } = state.settings
document.title = appName
gasApi.setUrl(apiUrl)
gasApi.setAuthToken(authToken)

Actions の中でクライアントを使ってリクエストを送信します。

/** 指定年月の家計簿データを取得します */
async fetchAbData ({ commit }, { yearMonth }) {
  const type = 'fetch'
  // 取得の前にローディングをtrueにする
  commit('setLoading', { type, v: true })
  try {
    // APIにリクエスト送信
    const res = await gasApi.fetch(yearMonth)
    // 取得できたらabDataにセットする
    commit('setAbData', { yearMonth, list: res.data })
  } catch (e) {
    // エラーが起きたらメッセージをセット
    commit('setErrorMessage', { message: e })
    // 空の配列をabDataにセット
    commit('setAbData', { yearMonth, list: [] })
  } finally {
    // 最後に成功/失敗関係なくローディングをfalseにする
    commit('setLoading', { type, v: false })
  }
}

ホーム画面で fetchAdData を呼んでいた箇所も変更が必要なので、対応させます。

Home.vue
export default {
  name: 'Home',

  /** ~ 省略 ~ */

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

    return {
      /** 月選択メニューの状態 */
      menu: false,
      /** 検索文字 */
      search: '',
      /** 選択年月 */
      yearMonth: `${year}-${month}`,
      /** テーブルに表示させるデータ */
      tableData: []
    }
  },

  computed: {
    ...mapState({
      /** 家計簿データ */
      abData: state => state.abData,
      /** ローディング状態 */
      loading: state => state.loading.fetch,
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    /** ~ 省略 ~ */

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

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

    /** ~ 省略 ~ */
  }
}

data の中で持っていた loading は消して、State の loading を使うようにします。

computed: {
  ...mapState({
    /** 家計簿データ */
    abData: state => state.abData,
    /** ローディング状態 */
    loading: state => state.loading.fetch,
  }),

  /** ~ 省略 ~ */
},

fetchAbDataPromise を返すようにしたので async/await に直します。

async updateTable () {
  /** ~ 省略 ~ */
  await this.fetchAbData({ yearMonth })
  /** ~ 省略 ~ */
},

このままだと通信でエラーが起きたときにメッセージが表示されないので、
App.vue にエラーメッセージを表示させるようにします。

App.vue
<template>
  <v-app>
    <!-- ~ 省略 ~ -->
    <v-main>
      <!-- ~ 省略 ~ -->
    </v-main>
    <!-- スナックバー -->
    <v-snackbar v-model="snackbar" color="error">{{ errorMessage }}</v-snackbar>
  </v-app>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'App',

  data () {
    return {
      snackbar: false
    }
  },

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

  watch: {
    errorMessage () {
      this.snackbar = true
    }
  },

  /** ~ 省略 ~ */
}
</script>

スナックバーは画面下に表示される、通知のようなものです

watcherrorMessage を監視して、変更のあったタイミングでスナックバーを表示させます。
スナックバーは一定時間経過すると自動で消えます。

watch: {
  // errorMessageに変更があったら
  errorMessage () {
    // スナックバーを表示
    this.snackbar = true
  }
},

API との疎通確認をしてみます!

家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
authToken を設定してない方は空のままでOKです。

ホーム画面に戻ってスプレッドシートのデータが表示されるか確認してみてください!

API で追加/更新できるようにする

次に、ItemDialog から API を使って追加/更新できるようにします。
さきほどと同じように Actions との内容を書き換えます。

store/index.js
/** ~ 省略 ~ */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    /** ~ 省略 ~ */
  },

  /** データを追加します */
  async addAbData ({ commit }, { item }) {
    const type = 'add'
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.add(item)
      commit('addAbData', { item: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },

  /** データを更新します */
  async updateAbData ({ commit }, { beforeYM, item }) {
    const type = 'update'
    const yearMonth = item.date.slice(0, 7)
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.update(beforeYM, item)
      if (yearMonth === beforeYM) {
        commit('updateAbData', { yearMonth, item })
        return
      }
      const id = item.id
      commit('deleteAbData', { yearMonth: beforeYM, id })
      commit('addAbData', { item: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */

ItemDialogasync/await に対応させます。

ItemDialog.vue
/** ~ 省略 ~ */
import { mapActions, mapGetters, mapState } from 'vuex'

export default {
  name: 'ItemDialog',

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

      /** 操作タイプ 'add' or 'edit' */
      actionType: 'add',
      /** ~ 省略 ~ */
    }
  },

  computed: {
    /** ~ 省略 ~ */

    ...mapState({
      /** ローディング状態 */
      loading: state => state.loading.add || state.loading.update
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    /** ~ 省略 ~ */

    /** 追加/更新がクリックされたとき */
    async 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') {
        await this.addAbData({ item })
      } else {
        item.id = this.id
        await this.updateAbData({ beforeYM: this.beforeYM, item })
      }

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

追加も編集も同じコンポーネントで行っているので、
どちらかが実行中であれば loading が true となるようにします。

...mapState({
  /** ローディング状態 */
  loading: state => state.loading.add || state.loading.update
}),

追加/編集がダイアログから実行できるか確認してみます!
どちらも実行できればOKです!スプレッドシートも確認してみてください。

API で削除できるようにする

最後に、DeleteDialog から API を使って削除できるようにします。

store/index.js
/** ~ 省略 ~ */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    /** ~ 省略 ~ */
  },

  /** データを追加します */
  async addAbData ({ commit }, { item }) {
    /** ~ 省略 ~ */
  },

  /** データを更新します */
  async updateAbData ({ commit }, { beforeYM, item }) {
    /** ~ 省略 ~ */
  },

  /** データを削除します */
  async deleteAbData ({ commit }, { item }) {
    const type = 'delete'
    const yearMonth = item.date.slice(0, 7)
    const id = item.id
    commit('setLoading', { type, v: true })
    try {
      await gasApi.delete(yearMonth, id)
      commit('deleteAbData', { yearMonth, id })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */
DeleteDialog.vue
/** ~ 省略 ~ */
import { mapActions, mapState } from 'vuex'

export default {
  name: 'DeleteDialog',

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

  computed: mapState({
    /** ローディング状態 */
    loading: state => state.loading.delete
  }),

  methods: {
    /** ~ 省略 ~ */

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

削除がダイアログから実行できるか確認してみます!
実行できればOKです!スプレッドシートも確認してみてください。

ハンズオンは以上になります。お疲れ様でした!:tada: :beer:

ホーム画面で収支の総計を確認できるようにしたり、毎月1日に先月の収入を自動で繰り越す GAS プログラムを追加したり…。

フロントに限らず、GAS 側も自分好みにしてみてください!

ハンズオン完成時点のソースコード一覧はこちらから確認できます!

さいごに

Vue.js の勉強用に作成したものなので、
改善できるところなどありましたらコメントで教えていただけると嬉しいです!

ハンズオンを最後まで進めていただいた方、上から飛んできた方も
最後まで閲覧いただきありがとうございました!:bow:

381
473
4

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
381
473