「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 で受け付けることのできるリクエストは GET
と POST
の2種類だけです。(doGet
, doPost
関数)
これでは REST API を作ることはできないので、
リクエスト内容にメソッドの文字列を入れることで擬似的に GET
, POST
, PUT
, DELETE
に対応させました!
家計簿は月ごとにシートを分けた
メリット
- 指定年月のデータ取得時の実行コストが低くなる
- データ数が増えても API が重くなりにくい
- スプレッドシートの内容を確認しやすい
指定年月のシートのデータをすべて取得すればいいので、「データが指定年月のものであるか?」を確認する必要がなくなります。
そのため、データ数が多くなっても1枚のシートで管理するより重くなりにくいです。
また、Webアプリ/スプレッドシートどちらからでも家計簿のデータを確認しやすいのが強みです。
デメリット
- データ年月の編集時の実行コストが高くなる
- 月をまたいだデータの取得/集計などが困難になる
編集前後でデータの年月を変えると、
「編集前の年月シートから削除」→「編集後の年月シートに追加」
する必要があるので、コストが高くなってしまいます。(そんな編集をすることは滅多にないと思いますが…)
また、今回作った API の仕様だと、1年分のデータを取得するのに、12回 API を叩く必要があります。
Webアプリでは月ごとの表示しかしていませんが、より細かい集計などするには API の改修が必要そうです。
家計簿アプリお試し方法
それでは、実際にこのアプリを試してみる方法を紹介します。
3ステップだけで完了します!
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部構成でお送りします!
- Vue.js / Vue Router / Vuex でフロント実装してみる
- Google Apps Script で REST API もどきを作ってみる
- 作った API と axios で実際に通信してみる
内容結構長いので、記事の最後まで飛びたい方はこちらをクリック。
環境構築
開発環境
Node.js と Yarn がインストールされている前提で進めます。
下記のバージョンと近いものか、高いものであれば基本動くと思います。
> 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 Router
と Vuex
を追加したいので、マニュアルで進めます。
(上下でカーソル移動、エンターで決定できます)
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」が表示されれば環境構築完了です!
この開発サーバーでは ホットリロード が有効なので、ファイル編集がすぐに反映されます。
以降はこのサーバーが起動している前提で進めて行きます。
現時点のソースコード一覧はこちらから確認できます!
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
が自動生成された状態のままなので、
不要なものを消してシンプルにします。
<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-btn
に to
属性を設定すると、ボタンが押されたときにそのパスへ移動できるからです。
また、v-icon
で Material Design Icons が使えます。
使い方は mdi-アイコン名
を v-icon
の中身に書くだけです。
<!-- テーブルアイコンのボタン -->
<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
はまだ作っていないので、何もない画面に切り替わります。
<!-- router-view の中身がパスによって切り替わる -->
<router-view></router-view>
ルーティングの設定は src/router/index.js
に書かれています。
このファイルを見てみましょう。
const routes = [
{
path: '/', // パスが "/" のときの設定
name: 'Home', // このルートに "Home" という名前をつける
component: Home // router-view の中に Home コンポーネントを表示する
},
この Home コンポーネント
は、3行目で読み込まれています。
/
では src/views/Home.vue
を表示しているようですね!
import Home from '../views/Home.vue'
ここまでの大雑把な流れは、
App.vue -> router -> views
ということがわかりました!
ページの中身を書き換えてみる
では、ページを中身を書き換えてみます。
ついでに views
ディレクトリの中に Settings.vue
も作りましょう。
どちらも中身はシンプルにします。
<template>
<div>
<h1>Home だよ</h1>
</div>
</template>
<script>
export default {
name: 'Home'
}
</script>
<template>
<div>
<h1>Settings だよ</h1>
</div>
</template>
<script>
export default {
name: 'Settings'
}
</script>
ルーティングの設定を変えて、Home
と Settings
が表示されるようにします。
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つを作っていきます。
<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>
…いきなり長いコードになってしまいました。
重要だと思うところを説明します。
検索フォームでは v-model
を使って入力されたデータを同期させています。
この場合は this.search
で入力された内容を読み取ることができます。
<!-- 検索フォーム -->
<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>
テーブルにはさまざまなプロパティを設定できます。
今回設定したものはこんな感じです。
<!-- テーブル -->
<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
でソート可否を設定できます。
/** テーブルのヘッダー設定 */
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桁区切りにしたいですよね。
次にこれを実装します。
~ 省略 ~
の部分に変更はありません。
<!-- ~ 省略 ~ -->
<!-- テーブル -->
<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
を作成します。
<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
を設定するだけで、いい感じにバリデーションしてくれます。
<!-- タイトル -->
<v-text-field
label="タイトル"
v-model.trim="title"
:counter="20"
:rules="titleRules"
/>
// v には現在入力されているデータが入ってる
v => /** OKにする条件 */ || /** NGのときに表示させる文字 */
ルールはこのように複数設定できます。
titleRules: [
v => v.trim().length > 0 || 'タイトルは必須です',
v => v.length <= 20 || '20文字以内で入力してください'
],
現状のままだとダイアログの動作確認できないので、
ホーム画面でダイアログを表示できるように ItemDialog.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
を作成します。
コードは少なめです
<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>
追加/編集ダイアログと同じように、ホームで表示させます。
<!-- ~ 省略 ~ -->
</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です。
現時点のソースコード一覧はこちらから確認できます!
設定の画面だけ作る
次に、手をつけていなかった設定画面を作ります。
<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>カンマ( , )区切りで入力してください。</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']
<!-- 収入カテゴリ -->
<v-text-field
label="収入カテゴリ"
v-model="settings.strIncomeItems"
:counter="150"
:rules="[stringRule, ...categoryRules]"
/>
設定を保存/読み込みできるようにする
設定画面で保存ボタンを押しても入力したデータは保存されていません。
また、この状態だとホーム画面で設定を読み込むこともできません。
ここで登場するのが Vuex
です。状態(State
)を管理できます。
公式ドキュメントにある画像がわかりやすかったので引用します。
とても大雑把に説明すると、
「画面から Actions
を使って状態更新」→「State
から状態読み込み」という流れになります。
今回は「設定」「家計簿データ」の状態管理に Vuex
を使用します。
さっそく、設定を保存/読み込みできるよう src/store/index.js
を書き換えます。
設定の内容は永続的に保存したいので、localStorage を利用します。
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 を使って設定保存できるようにします。
<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 から読み込む処理を追加します。
ついでにアプリ名を反映させます。
<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
を実装します。
/** ~ 省略 ~ */
/**
* 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 })
},
ホーム画面からストアを呼び出す
<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 から取得するのと、
フォームに入力されたデータで追加/更新できるようにします。
<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>
ダイアログからデータの追加/編集ができるか確認してみてください!
削除ダイアログからストアを呼び出す
<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 でフロント実装してみる」は以上になります。
お疲れ様でした!
現時点のソースコード一覧はこちらから確認できます!
Google Apps Script で REST API もどきを作ってみる
こちらから GAS で API の作成になります!!
「制作のポイント」でも触れましたが、擬似的にメソッドを指定して
GET
で取得、POST
で追加、PUT
で更新、DELETE
で削除できる API を作成します。
シート準備
まずはじめにシートの準備をします。
Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディター」をクリックします。
もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にしてください。
プロジェクトの名前を「家計簿API」と保存して、コード.gs
を api.gs
にリネームします。
api.gs
の内容を書き換えます。
const ss = SpreadsheetApp.getActive()
function test () {
console.log(ss.getName())
}
メニューで「test」が選択されていることを確認してから
ボタンをクリックします。
「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。
Ctrl
+ Enter
(mac は Command
+ Enter
) でログを確認できます。
作成したシートの名前が表示されればOKです。
家計簿のテンプレートをつくる
まずはじめに、家計簿のテンプレートとなるシートを作成する関数 insertTemplate
を作ります。
シートのイメージを大雑把にまとめると
です。これをプログラムに落とし込みます。
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 形式のほうが(個人的に)見やすいので、今回のプログラムではこちらに統一します。
/** 単一のセルを取得する */
// 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
を返すので、メソッドチェーンを利用できます。
可能な操作はすべて公式リファレンスに記載されているので、こちらも確認してみてください。
sheet.getRange('A1')
.func1() // どの操作も
.func2() // A1に対して
.func3() // 実行される
セル操作については重要な setValue
, setValues
メソッドを説明します。
単一セルの値をセットするときは setValue
、
複数セルの値をセットするときは setValues
を使います。
setValues
では必ず2次元配列を渡します。改行してみると分かりやすいです。
// 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
メソッドを使います。
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
を作成します。
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
というシンプルで便利なメソッドが用意されているので、
引数に配列を渡すだけで簡単にデータの追加をできます。
収支以外は文字列として扱ってほしいので、値の前にシングルクォートを付与してからシートに追加します。
値をセットするとき、文字列を渡しても数字や日付などは自動で変換されるので注意が必要です。
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 は Utilities
の getUuid
を利用して UUID の先頭8文字だけ切り取るという謎のプログラムで生成しています。
公式リファレンスで使える便利メソッドが記載されているので、ぜひ確認してみてください。
const id = Utilities.getUuid().slice(0, 8)
この状態で test を実行してみます。
シートが新しく作成され、データの追加を確認してください!
データ取得する onGet をつくる
追加ができたら、次は取得してみたいですね。onGet
を作ります。
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
でオブジェクトに加工します。
空白セルは空文字(''
)として取得されるので、収支だけ注意が必要です。
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
を作ります。
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 を探します。
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
を作ります。
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 }
}
/** ~ 省略 ~ */
編集だけ「更新前と後で年月が違う場合」を考慮しないといけません。
削除と追加の処理は onDelete
と onPost
に任せます。
// 更新前と後で年月が違う場合、データ削除と追加を実行
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 完成です!!
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 もどきを作ってみる」は以上になります。
お疲れ様でした!
現時点のソースコード一覧はこちらから確認できます!
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
を匿名で実行した場合は詳細が表示できないようです。
マークが表示されず、バージョンやステータスしか確認できません。
なので、今回はシートにログを記録したいと思います。
log
シートを作り、A1:C1
に「日付」「レベル」「メッセージ」を記入します。
api.gs
に log
関数を追加します。
一応ログは最大100件まで保存するようにしました。
logMaxRow
を書き換えれば最大保存件数を変更できます。
/** ~ 省略 ~ */
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: {
// 任意の処理の引数となるデータ
}
}
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 クライアントを使用して実際に通信してみます。
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
を呼んでいた箇所も変更が必要なので、対応させます。
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,
}),
/** ~ 省略 ~ */
},
fetchAbData
は Promise
を返すようにしたので async/await
に直します。
async updateTable () {
/** ~ 省略 ~ */
await this.fetchAbData({ yearMonth })
/** ~ 省略 ~ */
},
このままだと通信でエラーが起きたときにメッセージが表示されないので、
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>
watch
で errorMessage
を監視して、変更のあったタイミングでスナックバーを表示させます。
スナックバーは一定時間経過すると自動で消えます。
watch: {
// errorMessageに変更があったら
errorMessage () {
// スナックバーを表示
this.snackbar = true
}
},
API との疎通確認をしてみます!
家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
※ authToken
を設定してない方は空のままでOKです。
ホーム画面に戻ってスプレッドシートのデータが表示されるか確認してみてください!
API で追加/更新できるようにする
次に、ItemDialog
から API を使って追加/更新できるようにします。
さきほどと同じように Actions
との内容を書き換えます。
/** ~ 省略 ~ */
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 })
}
},
/** ~ 省略 ~ */
}
/** ~ 省略 ~ */
ItemDialog
も async/await
に対応させます。
/** ~ 省略 ~ */
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 を使って削除できるようにします。
/** ~ 省略 ~ */
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 })
}
},
/** ~ 省略 ~ */
}
/** ~ 省略 ~ */
/** ~ 省略 ~ */
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です!スプレッドシートも確認してみてください。
ハンズオンは以上になります。お疲れ様でした!
ホーム画面で収支の総計を確認できるようにしたり、毎月1日に先月の収入を自動で繰り越す GAS プログラムを追加したり…。
フロントに限らず、GAS 側も自分好みにしてみてください!
ハンズオン完成時点のソースコード一覧はこちらから確認できます!
さいごに
Vue.js の勉強用に作成したものなので、
改善できるところなどありましたらコメントで教えていただけると嬉しいです!
ハンズオンを最後まで進めていただいた方、上から飛んできた方も
最後まで閲覧いただきありがとうございました!