Go to list of users who liked
Share on X(Twitter)
Share on Facebook
More than 3 years have passed since last update.
【GAS x Vue.js】JavaScript のみで今、家計簿をつくるとしたら【ハンズオン付き!】
「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 -vv12.16.3> yarn -vv1.22.4Vue 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 Testinghistory mode を使うか?と尋ねられますが、今回は使わないので「n」を入力します。
? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) nESLint の設定はエラー防止のみの「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 servegas-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/johnleideryarn serve コマンドで開発サーバーを起動してみます。
> yarn servelocalhost: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-barappcolor="green"dark><!-- タイトル --><v-toolbar-title>GAS 家計簿</v-toolbar-title><v-spacer></v-spacer><!-- テーブルアイコンのボタン --><v-btniconto="/"><v-icon>mdi-file-table-outline</v-icon></v-btn><!-- 歯車アイコンのボタン --><v-btniconto="/settings"><v-icon>mdi-cog</v-icon></v-btn></v-app-bar><!-- メインコンテンツ --><v-main><v-containerfluid><!-- router-view の中身がパスによって切り替わる --><router-view></router-view></v-container></v-main></v-app></template><script>exportdefault{name:'App'}</script>ツールバーに表示されたボタンを押すと画面が切り替わると思います。
これは、v-btn にto 属性を設定すると、ボタンが押されたときにそのパスへ移動できるからです。
また、v-icon でMaterial Design Icons が使えます。
使い方はmdi-アイコン名 をv-icon の中身に書くだけです。
<!-- テーブルアイコンのボタン --><v-btniconto="/"><!-- クリックで "/" へ移動する --><v-icon>mdi-file-table-outline</v-icon></v-btn><!-- 歯車アイコンのボタン --><v-btniconto="/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 に書かれています。
このファイルを見てみましょう。
constroutes=[{path:'/',// パスが "/" のときの設定name:'Home',// このルートに "Home" という名前をつけるcomponent:Home// router-view の中に Home コンポーネントを表示する},このHome コンポーネント は、3行目で読み込まれています。/ ではsrc/views/Home.vue を表示しているようですね!
importHomefrom'../views/Home.vue'ここまでの大雑把な流れは、App.vue -> router -> views
ということがわかりました!
ページの中身を書き換えてみる
では、ページを中身を書き換えてみます。
ついでにviews ディレクトリの中にSettings.vue も作りましょう。
どちらも中身はシンプルにします。
<template><div><h1>Home だよ</h1></div></template><script>exportdefault{name:'Home'}</script><template><div><h1>Settings だよ</h1></div></template><script>exportdefault{name:'Settings'}</script>ルーティングの設定を変えて、Home とSettings が表示されるようにします。
importVuefrom'vue'importVueRouterfrom'vue-router'importHomefrom'../views/Home.vue'importSettingsfrom'../views/Settings.vue'Vue.use(VueRouter)constroutes=[{path:'/',name:'Home',component:Home},{path:'/settings',name:'Settings',component:Settings}]constrouter=newVueRouter({routes})exportdefaultrouterホームの画面だけ実装してみる
それでは、ホームの画面だけ実装していきましょう。
月選択フォーム、データ追加ボタン、検索フォーム、テーブル
の4つを作っていきます。
<template><div><v-card><v-card-title><!-- 月選択 --><v-colcols="8"><v-menuref="menu"v-model="menu":close-on-content-click="false":return-value.sync="yearMonth"transition="scale-transition"offset-ymax-width="290px"min-width="290px"><templatev-slot:activator="{ on }"><v-text-fieldv-model="yearMonth"prepend-icon="mdi-calendar"readonlyv-on="on"hide-details/></template><v-date-pickerv-model="yearMonth"type="month"color="green"locale="ja-jp"no-titlescrollable><v-spacer/><v-btntextcolor="grey"@click="menu = false">キャンセル</v-btn><v-btntextcolor="primary"@click="$refs.menu.save(yearMonth)">選択</v-btn></v-date-picker></v-menu></v-col><v-spacer/><!-- 追加ボタン --><v-colclass="text-right"cols="4"><v-btndarkcolor="green"><v-icon>mdi-plus</v-icon></v-btn></v-col><!-- 検索フォーム --><v-colcols="12"><v-text-fieldv-model="search"append-icon="mdi-magnify"label="Search"single-linehide-details/></v-col></v-card-title><!-- テーブル --><v-data-tableclass="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>exportdefault{name:'Home',data(){consttoday=newDate()constyear=today.getFullYear()constmonth=('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-colcols="12"><v-text-fieldv-model="search"入力したデータをthis.searchと同期append-icon="mdi-magnify"検索アイコンlabel="Search"ラベル名single-line1行だけ入力できるhide-details文字カウントなどを非表示/></v-col>テーブルにはさまざまなプロパティを設定できます。
今回設定したものはこんな感じです。
<!-- テーブル --><v-data-tableclass="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~省略~><!-- 日付列 --><templatev-slot:item.date="{ item }">{{parseInt(item.date.slice(-2))+'日'}}</template><!-- タグ列 --><templatev-slot:item.tags="{ item }"><divv-if="item.tags"><v-chipclass="mr-2"v-for="(tag, i) in item.tags.split(',')":key="i">{{tag}}</v-chip></div></template><!-- 収入列 --><templatev-slot:item.income="{ item }">{{separate(item.income)}}</template><!-- タグ列 --><templatev-slot:item.outgo="{ item }">{{separate(item.outgo)}}</template><!-- 操作列 --><templatev-slot:item.actions="{}"><v-iconclass="mr-2">mdi-pencil</v-icon><v-icon>mdi-delete</v-icon></template></v-data-table><!-- ~ 省略 ~ -->/** ~ 省略 ~ */<script>exportdefault{name:'Home',data(){/** ~ 省略 ~ */},computed:{/** ~ 省略 ~ */},methods:{/** * 数字を3桁区切りにして返します。 * 受け取った数が null のときは null を返します。 */separate(num){returnnum!==null?num.toString().replace(/(\d)(?=(\d{3})+$)/g,'$1,'):null}}}</script>これは Vuetify の決まりごとになってしまいますが、v-data-table 内のtemplate でv-slot:item.列名="{ item }" とすると、その列のデータを加工できます。
<!-- 日付列 --><templatev-slot:item.date="{ item }"><!-- この中で、日付は item.date でアクセスできる --><!-- '2020-06-01' → '1日' に加工 -->{{parseInt(item.date.slice(-2))+'日'}}</template>現時点のソースコード一覧はこちらから確認できます!
操作ダイアログを作る
データを追加/編集するダイアログを作ります。
新しくcomponents ディレクトリの中にItemDialog.vue を作成します。
<template><!-- データ追加/編集ダイアログ --><v-dialogv-model="show"scrollablepersistentmax-width="500px"eager><v-card><v-card-title>{{titleText}}</v-card-title><v-divider/><v-card-text><v-formref="form"v-model="valid"><!-- 日付選択 --><v-menuref="menu"v-model="menu":close-on-content-click="false":return-value.sync="date"transition="scale-transition"offset-ymax-width="290px"min-width="290px"><templatev-slot:activator="{ on }"><v-text-fieldv-model="date"prepend-icon="mdi-calendar"readonlyv-on="on"hide-details/></template><v-date-pickerv-model="date"color="green"locale="ja-jp":day-format="date => new Date(date).getDate()"no-titlescrollable><v-spacer/><v-btntextcolor="grey"@click="menu = false">キャンセル</v-btn><v-btntextcolor="primary"@click="$refs.menu.save(date)">選択</v-btn></v-date-picker></v-menu><!-- タイトル --><v-text-fieldlabel="タイトル"v-model.trim="title":counter="20":rules="titleRules"/><!-- 収支 --><v-radio-grouprowv-model="inout"hide-details@change="onChangeInout"><v-radiolabel="収入"value="income"/><v-radiolabel="支出"value="outgo"/></v-radio-group><!-- カテゴリ --><v-selectlabel="カテゴリ"v-model="category":items="categoryItems"hide-details/><!-- タグ --><v-selectlabel="タグ"v-model="tags":items="tagItems"multiplechips:rules="[tagRule]"/><!-- 金額 --><v-text-fieldlabel="金額"v-model.number="amount"prefix="¥"pattern="[0-9]*":rules="amountRules"/><!-- メモ --><v-text-fieldlabel="メモ"v-model="memo":counter="50":rules="[memoRule]"/></v-form></v-card-text><v-divider/><v-card-actions><v-spacer/><v-btncolor="grey darken-1"text:disabled="loading"@click="onClickClose"> キャンセル</v-btn><v-btncolor="blue darken-1"text:disabled="!valid":loading="loading"@click="onClickAction"> {{ actionText }}</v-btn></v-card-actions></v-card></v-dialog></template><script>exportdefault{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(){returnthis.actionType==='add'?'データ追加':'データ編集'},/** ダイアログのアクション */actionText(){returnthis.actionType==='add'?'追加':'更新'}},methods:{/** * ダイアログを表示します。 * このメソッドは親から呼び出されます。 */open(actionType,item){this.show=truethis.actionType=actionTypethis.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={}){consttoday=newDate()constyear=today.getFullYear()constmonth=('0'+(today.getMonth()+1)).slice(-2)constdate=('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.incomeItemsthis.amount=item.income||0}else{this.categoryItems=this.outgoItemsthis.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-fieldlabel="タイトル"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-colclass="text-right"cols="4"><v-btndarkcolor="green"@click="onClickAdd"><v-icon>mdi-plus</v-icon></v-btn></v-col><!-- ~ 省略 ~ --></v-card-title><!-- テーブル --><v-data-table><!-- ~ 省略 ~ --><!-- 操作列 --><templatev-slot:item.actions="{ item }"><v-iconclass="mr-2"@click="onClickEdit(item)">mdi-pencil</v-icon><v-icon>mdi-delete</v-icon></template></v-data-table></v-card><!-- 追加/編集ダイアログ --><ItemDialogref="itemDialog"/></div></template><script>importItemDialogfrom'../components/ItemDialog.vue'exportdefault{name:'Home',components:{ItemDialog},/** ~ 省略 ~ */methods:{/** ~ 省略 ~ *//** 追加ボタンがクリックされたとき */onClickAdd(){this.$refs.itemDialog.open('add')},/** 編集ボタンがクリックされたとき */onClickEdit(item){this.$refs.itemDialog.open('edit',item)}}}</script>テーブル右上に表示されている追加ボタン、
操作列の編集ボタンをクリックして、動作を確認してみます。
追加ボタンをクリックしたときは何も入力されていないフォーム、
編集ボタンをクリックしたときは初期値が入力されているフォームが表示されればOKです。
バリデーションも実行されるか確認してみます。
問題なく動いてそうです。
コンポーネントの子要素にはref 属性をつけるとthis.$refs.名前 でアクセスできます。
<!-- 追加/編集ダイアログ --><ItemDialogref="itemDialog"/>今回はダイアログにitemDialog という名前をつけたので、this.$refs.itemDialog ですね。
追加ボタンをクリックしたとき、追加/編集ダイアログのopen を実行することで
ダイアログの表示を行うようにしています。
/** 追加ボタンがクリックされたとき */onClickAdd(){this.$refs.itemDialog.open('add')},追加/編集ダイアログと同じように削除ダイアログも作成します。
新しくcomponents ディレクトリの中にDeleteDialog.vue を作成します。
コードは少なめです
<template><!-- 削除ダイアログ --><v-dialogv-model="show"persistentmax-width="290"><v-card><v-card-title/><v-card-textclass="black--text"> 「{{ item.title }}」を削除しますか?</v-card-text><v-card-actions><v-spacer/><v-btncolor="grey"text:disabled="loading"@click="onClickClose">キャンセル</v-btn><v-btncolor="red"text:loading="loading"@click="onClickDelete">削除</v-btn></v-card-actions></v-card></v-dialog></template><script>exportdefault{name:'DeleteDialog',data(){return{/** ダイアログの表示状態 */show:false,/** ローディング状態 */loading:false,/** 受け取ったデータ */item:{}}},methods:{/** * ダイアログを表示します。 * このメソッドは親から呼び出されます。 */open(item){this.show=truethis.item=item},/** キャンセルがクリックされたとき */onClickClose(){this.show=false},/** 削除がクリックされたとき */onClickDelete(){// あとで実装}}}</script>追加/編集ダイアログと同じように、ホームで表示させます。
<!-- ~ 省略 ~ --></v-card><!-- 追加/編集ダイアログ --><ItemDialogref="itemDialog"/><!-- 削除ダイアログ --><DeleteDialogref="deleteDialog"/></div></template><script>importItemDialogfrom'../components/ItemDialog.vue'importDeleteDialogfrom'../components/DeleteDialog.vue'exportdefault{name:'Home',components:{ItemDialog,DeleteDialog},/** ~ 省略 ~ */methods:{/** ~ 省略 ~ *//** 削除ボタンがクリックされたとき */onClickDelete(item){this.$refs.deleteDialog.open(item)}}}</script>削除ボタンをクリックして、ダイアログが表示されればOkです。
現時点のソースコード一覧はこちらから確認できます!
設定の画面だけ作る
次に、手をつけていなかった設定画面を作ります。
<template><divclass="form-wrapper"><p>※設定はこのデバイスのみに保存されます。</p><v-formv-model="valid"><h3>アプリ設定</h3><!-- アプリ名 --><v-text-fieldlabel="アプリ名"v-model="settings.appName":counter="30":rules="[appNameRule]"/><!-- API URL --><v-text-fieldlabel="API URL"v-model="settings.apiUrl":counter="150":rules="[stringRule]"/><!-- Auth Token --><v-text-fieldlabel="Auth Token"v-model="settings.authToken":counter="150":rules="[stringRule]"/><h3>カテゴリ/タグ設定</h3><p>カンマ(, )区切りで入力してください。</p><!-- 収入カテゴリ --><v-text-fieldlabel="収入カテゴリ"v-model="settings.strIncomeItems":counter="150":rules="[stringRule, ...categoryRules]"/><!-- 支出カテゴリ --><v-text-fieldlabel="支出カテゴリ"v-model="settings.strOutgoItems":counter="150":rules="[stringRule, ...categoryRules]"/><!-- タグ --><v-text-fieldlabel="タグ"v-model="settings.strTagItems":counter="150":rules="[stringRule, tagRule]"/><v-rowclass="mt-4"><v-spacer/><v-btncolor="primary":disabled="!valid"@click="onClickSave">保存</v-btn></v-row></v-form></div></template><script>exportdefault{name:'Settings',data(){constcreateItems=v=>v.split(',').map(v=>v.trim()).filter(v=>v.length!==0)constitemMaxLength=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>追加/編集ダイアログと同じようにフォームを表示させ、バリデーションさせています。
スプレッド構文を使うと、いい感じにバリデーションルールを使い回せます。
construles=['rule2','rule3']console.log(['rule1',...rules])// -> ['rule1', 'rule2', 'rule3']<!-- 収入カテゴリ --><v-text-fieldlabel="収入カテゴリ"v-model="settings.strIncomeItems":counter="150":rules="[stringRule, ...categoryRules]"/>設定を保存/読み込みできるようにする
設定画面で保存ボタンを押しても入力したデータは保存されていません。
また、この状態だとホーム画面で設定を読み込むこともできません。
ここで登場するのがVuex です。状態(State)を管理できます。
公式ドキュメントにある画像がわかりやすかったので引用します。
とても大雑把に説明すると、
「画面からActions を使って状態更新」→「State から状態読み込み」という流れになります。
今回は「設定」「家計簿データ」の状態管理にVuex を使用します。
さっそく、設定を保存/読み込みできるようsrc/store/index.js を書き換えます。
設定の内容は永続的に保存したいので、localStorage を利用します。
importVuefrom'vue'importVuexfrom'vuex'Vue.use(Vuex)/** * State * Vuexの状態 */conststate={/** 設定 */settings:{appName:'GAS 家計簿',apiUrl:'',authToken:'',strIncomeItems:'給料, ボーナス, 繰越',strOutgoItems:'食費, 趣味, 交通費, 買い物, 交際費, 生活費, 住宅, 通信, 車, 税金',strTagItems:'固定費, カード'}}/** * Mutations * ActionsからStateを更新するときに呼ばれます */constmutations={/** 設定を保存します */saveSettings(state,{settings}){state.settings={...settings}document.title=state.settings.appNamelocalStorage.setItem('settings',JSON.stringify(settings))},/** 設定を読み込みます */loadSettings(state){constsettings=JSON.parse(localStorage.getItem('settings'))if(settings){state.settings=Object.assign(state.settings,settings)}document.title=state.settings.appName}}/** * Actions * 画面から呼ばれ、Mutationをコミットします */constactions={/** 設定を保存します */saveSettings({commit},{settings}){commit('saveSettings',{settings})},/** 設定を読み込みます */loadSettings({commit}){commit('loadSettings')}}/** カンマ区切りの文字をトリミングして配列にします */constcreateItems=v=>v.split(',').map(v=>v.trim()).filter(v=>v.length!==0)/** * Getters * 画面から取得され、Stateを加工して渡します */constgetters={/** 収入カテゴリ(配列) */incomeItems(state){returncreateItems(state.settings.strIncomeItems)},/** 支出カテゴリ(配列) */outgoItems(state){returncreateItems(state.settings.strOutgoItems)},/** タグ(配列) */tagItems(state){returncreateItems(state.settings.strTagItems)}}conststore=newVuex.Store({state,mutations,actions,getters})exportdefaultstore突然Mutations,Getters が現れました。
こちらも公式ドキュメント画像の引用になりますが、
Vuex では「Actions」→「Mutations」→「State」という流れで状態を更新します。
State は Mutations からしか変更しないようにします
Getters はコメントにもありますが、State を加工して渡します。
Vuex 版computed のようなものです。
次に、設定画面で Vuex を使って設定保存できるようにします。
<script>exportdefault{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-barappcolor="green"dark><!-- タイトル --><v-toolbar-title>{{appName}}</v-toolbar-title><!-- ~ 省略 ~ --></v-app-bar><!-- ~ 省略 ~ --></v-app></template><script>import{mapState}from'vuex'exportdefault{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の状態 */conststate={/** 家計簿データ */abData:{},/** ~ 省略 ~ */}/** * Mutations * ActionsからStateを更新するときに呼ばれます */constmutations={/** 指定年月の家計簿データをセットします */setAbData(state,{yearMonth,list}){state.abData[yearMonth]=list},/** データを追加します */addAbData(state,{item}){constyearMonth=item.date.slice(0,7)constlist=state.abData[yearMonth]if(list){list.push(item)}},/** 指定年月のデータを更新します */updateAbData(state,{yearMonth,item}){constlist=state.abData[yearMonth]if(list){constindex=list.findIndex(v=>v.id===item.id)list.splice(index,1,item)}},/** 指定年月&IDのデータを削除します */deleteAbData(state,{yearMonth,id}){constlist=state.abData[yearMonth]if(list){constindex=list.findIndex(v=>v.id===id)list.splice(index,1)}},/** ~ 省略 ~ */}/** * Actions * 画面から呼ばれ、Mutationをコミットします */constactions={/** 指定年月の家計簿データを取得します */fetchAbData({commit},{yearMonth}){// サンプルデータを初期値として入れるconstlist=[{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}){constyearMonth=item.date.slice(0,7)if(yearMonth===beforeYM){commit('updateAbData',{yearMonth,item})return}constid=item.idcommit('deleteAbData',{yearMonth:beforeYM,id})commit('addAbData',{item})},/** データを削除します */deleteAbData({commit},{item}){constyearMonth=item.date.slice(0,7)constid=item.idcommit('deleteAbData',{yearMonth,id})},/** ~ 省略 ~ */}/** ~ 省略 ~ */家計簿データを取得/追加/更新/削除する処理を追加しました。
どの処理も API 完成後に通信させます。
今回の実装内容は家計簿データの操作なので、複雑な処理はありませんが、
更新だけ少し特殊なので補足します。
// (Actions)/** データを更新します */updateAbData({commit},{beforeYM,item}){constyearMonth=item.date.slice(0,7)// 更新前後で年月の変更が無ければそのまま値を更新if(yearMonth===beforeYM){commit('updateAbData',{yearMonth,item})return}// 更新があれば、更新前年月のデータから削除して、新しくデータ追加するconstid=item.idcommit('deleteAbData',{yearMonth:beforeYM,id})commit('addAbData',{item})},ホーム画面からストアを呼び出す
<template><div><v-card><v-card-title><!-- 月選択 --><v-colcols="8"><v-menu~省略~><!-- ~ 省略 ~ --><v-date-picker~省略~><v-spacer/><v-btntextcolor="grey"@click="menu = false">キャンセル</v-btn><v-btntextcolor="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'/** ~ 省略 ~ */exportdefault{/** ~ 省略 ~ */data(){/** ~ 省略 ~ */return{/** ~ 省略 ~ *//** テーブルに表示させるデータ */tableData:[]}},computed:{...mapState({/** 家計簿データ */abData:state=>state.abData}),/** ~ 省略 ~ */},methods:{...mapActions([/** 家計簿データを取得 */'fetchAbData']),/** 表示させるデータを更新します */updateTable(){constyearMonth=this.yearMonthconstlist=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'exportdefault{name:'ItemDialog',data(){return{/** ~ 省略 ~ *//** メモ */memo:'',/** 選択可能カテゴリ一覧 */categoryItems:[],/** 編集前の年月(編集時に使う) */beforeYM:'',/** ~ 省略 ~ */}},computed:{...mapGetters([/** 収支カテゴリ */'incomeItems','outgoItems',/** タグ */'tagItems']),/** ~ 省略 ~ */},methods:{...mapActions([/** データ追加 */'addAbData',/** データ更新 */'updateAbData']),/** ~ 省略 ~ *//** 追加/更新がクリックされたとき */onClickAction(){constitem={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||0if(this.actionType==='add'){item.id=Math.random().toString(36).slice(-8)// ランダムな8文字のIDを生成this.addAbData({item})}else{item.id=this.idthis.updateAbData({beforeYM:this.beforeYM,item})}this.show=false},/** ~ 省略 ~ */}}</script>ダイアログからデータの追加/編集ができるか確認してみてください!
削除ダイアログからストアを呼び出す
<script>import{mapActions}from'vuex'exportdefault{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 の内容を書き換えます。
constss=SpreadsheetApp.getActive()functiontest(){console.log(ss.getName())}メニューで「test」が選択されていることを確認してから
ボタンをクリックします。
「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。
Ctrl +Enter (mac はCommand +Enter) でログを確認できます。
作成したシートの名前が表示されればOKです。
家計簿のテンプレートをつくる
まずはじめに、家計簿のテンプレートとなるシートを作成する関数insertTemplate を作ります。
シートのイメージを大雑把にまとめると
です。これをプログラムに落とし込みます。
constss=SpreadsheetApp.getActive()functiontest(){insertTemplate('2020-06')}/** * 指定年月のテンプレートシートを作成します * @param {String} yearMonth * @returns {Sheet} sheet */functioninsertTemplate(yearMonth){const{SOLID_MEDIUM,DOUBLE}=SpreadsheetApp.BorderStyleconstsheet=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)returnsheet}スプレッドシートはSpreadsheetApp を利用して取得します。
取得の方法は2つあります。
- スプレッドシートIDを指定する
openById(id) - 紐付いているスプレッドシートを取得する
getActive()
今回はスプレッドシートと紐付いている GAS プロジェクトを作成したので、後者で取得します。
constss=SpreadsheetApp.getActive()新規シートを作成するときにはinsertSheet メソッドを使います。
引数にシート名とインデックスを指定します。インデックスは0 で一番左に追加されます。
返り値は新規作成したシートです。
constsheet=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 を作成します。
constss=SpreadsheetApp.getActive()functiontest(){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} 追加した家計簿データ */functiononPost({item}){if(!isValid(item)){return{error:'正しい形式で入力してください'}}const{date,title,category,tags,income,outgo,memo}=itemconstyearMonth=date.slice(0,7)constsheet=ss.getSheetByName(yearMonth)||insertTemplate(yearMonth)constid=Utilities.getUuid().slice(0,8)constrow=["'"+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 */functioninsertTemplate(yearMonth){/** ~ 省略 ~ */}/** * データが正しい形式か検証します * @param {Object} item * @returns {Boolean} isValid */functionisValid(item={}){conststrKeys=['date','title','category','tags','memo']constkeys=[...strKeys,'income','outgo']// すべてのキーが存在するかfor(constkeyofkeys){if(item[key]===undefined)returnfalse}// 収支以外が文字列であるかfor(constkeyofstrKeys){if(typeofitem[key]!=='string')returnfalse}// 日付が正しい形式であるかconstdateReg=/^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/if(!dateReg.test(item.date))returnfalse// 収支のどちらかが入力されているかconst{income:i,outgo:o}=itemif((i===null&&o===null)||(i!==null&&o!==null))returnfalse// 入力された収支が数字であるかif(i!==null&&typeofi!=='number')returnfalseif(o!==null&&typeofo!=='number')returnfalsereturntrue}シートの取得はgetSheetByName でシート名を指定して取得します。
シートがなかった場合はnull が返ってくるので、insertTemplate が実行されます。
// 指定年月シートを取得する、なかったらテンプレートシートを作成するconstsheet=ss.getSheetByName(yearMonth)||insertTemplate(yearMonth)また、シートにはappendRow というシンプルで便利なメソッドが用意されているので、
引数に配列を渡すだけで簡単にデータの追加をできます。
収支以外は文字列として扱ってほしいので、値の前にシングルクォートを付与してからシートに追加します。
値をセットするとき、文字列を渡しても数字や日付などは自動で変換されるので注意が必要です。
consta1=sheet.getRange('A1').setValue("100").getValue()constb1=sheet.getRange('B1').setValue("'100").getValue()console.log(typeofa1)// -> "number"console.log(typeofb1)// -> "string"ID はUtilities のgetUuid を利用して UUID の先頭8文字だけ切り取るという謎のプログラムで生成しています。
公式リファレンスで使える便利メソッドが記載されているので、ぜひ確認してみてください。
constid=Utilities.getUuid().slice(0,8)この状態で test を実行してみます。
シートが新しく作成され、データの追加を確認してください!
データ取得する onGet をつくる
追加ができたら、次は取得してみたいですね。onGet を作ります。
constss=SpreadsheetApp.getActive()functiontest(){constresult=onGet({yearMonth:'2020-07'})console.log(result)}/** --- API --- *//** * 指定年月のデータ一覧を取得します * @param {Object} params * @param {String} params.yearMonth 年月 * @returns {Object[]} 家計簿データ */functiononGet({yearMonth}){constymReg=/^[0-9]{4}-(0[1-9]|1[0-2])$/if(!ymReg.test(yearMonth)){return{error:'正しい形式で入力してください'}}constsheet=ss.getSheetByName(yearMonth)constlastRow=sheet?sheet.getLastRow():0if(lastRow<7){return[]}constlist=sheet.getRange('A7:H'+lastRow).getValues().map(row=>{const[id,date,title,category,tags,income,outgo,memo]=rowreturn{id,date,title,category,tags,income:(income==='')?null:income,outgo:(outgo==='')?null:outgo,memo}})returnlist}/** ~ 省略 ~ */テーブルのヘッダーがA6:H6 にあるので、A7:H{最終行} のデータを取得します。
シートの最終行はgetLastRow で取得できます。
指定年月のシートが存在しない場合も考慮して、最終行が7未満の場合は空の配列を返します。
constsheet=ss.getSheetByName(yearMonth)constlastRow=sheet?sheet.getLastRow():0if(lastRow<7){return[]}データを返すときはオブジェクトにして返したいので、getValues で受け取った2次元配列をmap でオブジェクトに加工します。
空白セルは空文字('')として取得されるので、収支だけ注意が必要です。
constvalues=[['xxx','2020-07-01','sample1'],['yyy','2020-07-02','sample2']]constlist=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 を作ります。
constss=SpreadsheetApp.getActive()functiontest(){constresult=onDelete({yearMonth:'2020-07',id:'xxxxxxxx'})console.log(result)}/** --- API --- */functiononGet({yearMonth}){/** ~ 省略 ~ */}functiononPost({item}){/** ~ 省略 ~ */}/** * 指定年月&idのデータを削除します * @param {Object} params * @param {String} params.yearMonth 年月 * @param {String} params.id id * @returns {Object} メッセージ */functiononDelete({yearMonth,id}){constymReg=/^[0-9]{4}-(0[1-9]|1[0-2])$/constsheet=ss.getSheetByName(yearMonth)if(!ymReg.test(yearMonth)||sheet===null){return{error:'指定のシートは存在しません'}}constlastRow=sheet.getLastRow()constindex=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 を探します。
constvalues=[['xxx'],['yyy'],['zzz']]constflatted=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 を作ります。
constss=SpreadsheetApp.getActive()functiontest(){onPut({beforeYM:'2020-07',item:{id:'xxxxxxxx',date:'2020-07-31',title:'更新サンプル',category:'食費',tags:'タグ1,タグ2',income:null,outgo:5000,memo:'更新したよ'}})}/** --- API --- */functiononGet({yearMonth}){/** ~ 省略 ~ */}functiononPost({item}){/** ~ 省略 ~ */}functiononDelete({yearMonth,id}){/** ~ 省略 ~ */}/** * 指定データを更新します * @param {Object} params * @param {String} params.beforeYM 更新前の年月 * @param {Object} params.item 家計簿データ * @returns {Object} 更新後の家計簿データ */functiononPut({beforeYM,item}){constymReg=/^[0-9]{4}-(0[1-9]|1[0-2])$/if(!ymReg.test(beforeYM)||!isValid(item)){return{error:'正しい形式で入力してください'}}// 更新前と後で年月が違う場合、データ削除と追加を実行constyearMonth=item.date.slice(0,7)if(beforeYM!==yearMonth){onDelete({yearMonth:beforeYM,id:item.id})returnonPost({item})}constsheet=ss.getSheetByName(yearMonth)if(sheet===null){return{error:'指定のシートは存在しません'}}constid=item.idconstlastRow=sheet.getLastRow()constindex=sheet.getRange('A7:A'+lastRow).getValues().flat().findIndex(v=>v===id)if(index===-1){return{error:'指定のデータは存在しません'}}constrow=index+7const{date,title,category,tags,income,outgo,memo}=itemconstvalues=[["'"+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 に任せます。
// 更新前と後で年月が違う場合、データ削除と追加を実行constyearMonth=item.date.slice(0,7)if(beforeYM!==yearMonth){onDelete({yearMonth:beforeYM,id:item.id})returnonPost({item})}同じシートで完結できる場合は id 列以外のB?:H? をsetValues で更新します。
編集する行はデータ削除の時と同じように探します。
constvalues=[["'"+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 に振り分ける処理を追加します。
constss=SpreadsheetApp.getActive()constauthToken=PropertiesService.getScriptProperties().getProperty('authToken')||''/** * レスポンスを作成して返します * @param {*} content * @returns {TextOutput} */functionresponse(content){constres=ContentService.createTextOutput()res.setMimeType(ContentService.MimeType.JSON)res.setContent(JSON.stringify(content))returnres}/** * アプリにPOSTリクエストが送信されたとき実行されます * @param {Event} e * @returns {TextOutput} */functiondoPost(e){letcontentstry{contents=JSON.parse(e.postData.contents)}catch(e){returnresponse({error:'JSONの形式が正しくありません'})}if(contents.authToken!==authToken){returnresponse({error:'認証に失敗しました'})}const{method='',params={}}=contentsletresulttry{switch(method){case'POST':result=onPost(params)breakcase'GET':result=onGet(params)breakcase'PUT':result=onPut(params)breakcase'DELETE':result=onDelete(params)breakdefault:result={error:'methodを指定してください'}}}catch(e){result={error:e}}returnresponse(result)}/** --- API --- *//** ~ 省略 ~ */GAS でレスポンスを返すときはContentService を利用します。
作成した API では JSON しか返さないので mime type にはMimeType.JSON を指定します。
functionresponse(content){constres=ContentService.createTextOutput()// レスポンスの Content-Type ヘッダーに "application/json" を設定するres.setMimeType(ContentService.MimeType.JSON)// オブジェクトを文字列にしてからレスポンスに詰め込むres.setContent(JSON.stringify(content))returnres}次にdoPost の中をみていきます。
送られたリクエストはe.postData.contents で取得できます。
文字列なので JSON にパースします。一応 try catch で囲んでおきます。
letcontentstry{contents=JSON.parse(e.postData.contents)}catch(e){returnresponse({error:'JSONの形式が正しくありません'})}受け取るリクエストの内容はこのような形式としてます。
{method:'GET or POST or PUT or DELETE',authToken:'認証情報',params:{// 任意の処理の引数となるデータ}}誰でもアクセス可能な URL を発行するので、認証情報authToken を持っている人しかアクセスできないようにします。
認証情報はソースコードに書きたくないので、PropertiesService を利用してスクリプトのプロパティから取得します。
「ファイル」タブ→「プロジェクトのプロパティ」→「スクリプトのプロパティ」から設定できます。
constauthToken=PropertiesService.getScriptProperties().getProperty('authToken')||''処理はシンプルに case 文で分けます。
実行中にエラー起きても大丈夫なように、一応 try catch で囲んでおきます。
letresulttry{switch(method){case'POST':result=onPost(params)breakcase'GET':result=onGet(params)breakcase'PUT':result=onPut(params)breakcase'DELETE':result=onDelete(params)breakdefault:result={error:'methodを指定してください'}}}catch(e){result={error:e}}最後に実行結果をレスポンスとして返します。
returnresponse(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度実行してからダッシュボードで確認すると、
functiontest(){console.log('log')console.info('info')console.warn('warn')console.error('error')}このようにログが表示されます。
しかしdoPost を匿名で実行した場合は詳細が表示できないようです。
マークが表示されず、バージョンやステータスしか確認できません。
なので、今回はシートにログを記録したいと思います。
log シートを作り、A1:C1 に「日付」「レベル」「メッセージ」を記入します。
api.gs にlog 関数を追加します。
一応ログは最大100件まで保存するようにしました。logMaxRow を書き換えれば最大保存件数を変更できます。
/** ~ 省略 ~ */constlogMaxRow=101constlogSheet=ss.getSheetByName('log')/** * ログをシートに記録します * @param {String} level * @param {String} message */functionlog(level,message){logSheet.appendRow([newDate(),level.toUpperCase(),message])if(logMaxRow<logSheet.getLastRow()){logSheet.deleteRow(2)}}この状態で test の内容を書き換えて実行してみます。
functiontest(){log('info','info メッセージ')log('warn','warn メッセージ')log('error','error メッセージ')}log シートがこのように書き換わります。
あとは、API の好きな部分でlog を実行するだけです!
シートに条件付き書式などを設定して、見やすくすると良さそうです。
スプレッドシートの「フィルタ表示」機能を使うとフィルタリングもできます!
ログを記録するサンプルコードはこちらから確認できます!
作った API と axios で実際に通信してみる
それではフロントと API を連携させて、家計簿を完成させていきます!
まずは、axios というライブラリをプロジェクトに追加します。
API にアクセスする際よく利用されます。
> yarn add axiosVuex の中でaxios を使って API にアクセスします。
この図のActions <---> Backend API の部分を実装します。
API クライアントをつくる
src の中に新しくapi ディレクトリを作成し、
その中にgasApi.js を作成します。
このリクエストを送れるようにします。
{method:'GET or POST or PUT or DELETE',authToken:'認証情報',params:{// 任意の処理の引数となるデータ}}importaxiosfrom'axios'// 共通のヘッダーを設定したaxiosのインスタンス作成constgasApi=axios.create({headers:{'content-type':'application/x-www-form-urlencoded'}})// response共通処理// errorが含まれていたらrejectするgasApi.interceptors.response.use(res=>{if(res.data.error){returnPromise.reject(res.data.error)}returnPromise.resolve(res)},err=>{returnPromise.reject(err)})/** * APIのURLを設定します * @param {String} url */constsetUrl=url=>{gasApi.defaults.baseURL=url}/** * authTokenを設定します * @param {String} token */letauthToken=''constsetAuthToken=token=>{authToken=token}/** * 指定年月のデータを取得します * @param {String} yearMonth * @returns {Promise} */constfetch=yearMonth=>{returngasApi.post('',{method:'GET',authToken,params:{yearMonth}})}/** * データを追加します * @param {Object} item * @returns {Promise} */constadd=item=>{returngasApi.post('',{method:'POST',authToken,params:{item}})}/** * 指定年月&idのデータを削除します * @param {String} yearMonth * @param {String} id * @returns {Promise} */const$delete=(yearMonth,id)=>{returngasApi.post('',{method:'DELETE',authToken,params:{yearMonth,id}})}/** * データを更新します * @param {String} beforeYM * @param {Object} item * @returns {Promise} */constupdate=(beforeYM,item)=>{returngasApi.post('',{method:'PUT',authToken,params:{beforeYM,item}})}exportdefault{setUrl,setAuthToken,fetch,add,delete:$delete,update}最初に共通の設定をしたインスタンスを作成します。あとからデフォルト設定を上書きもできます。
// 共通のヘッダーを設定したaxiosのインスタンス作成constgasApi=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){returnPromise.reject(res.data.error)}returnPromise.resolve(res)},err=>{returnPromise.reject(err)})API からデータを取得する
それでは、作成した API クライアントを使用して実際に通信してみます。
importVuefrom'vue'importVuexfrom'vuex'importgasApifrom'../api/gasApi'Vue.use(Vuex)/** * State * Vuexの状態 */conststate={/** 家計簿データ */abData:{},/** ローディング状態 */loading:{fetch:false,add:false,update:false,delete:false},/** エラーメッセージ */errorMessage:'',/** 設定 */settings:{/** ~ 省略 ~ */}}/** * Mutations * ActionsからStateを更新するときに呼ばれます */constmutations={/** ~ 省略 ~ *//** ローディング状態をセットします */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.settingsdocument.title=appNamegasApi.setUrl(apiUrl)gasApi.setAuthToken(authToken)// 家計簿データを初期化state.abData={}localStorage.setItem('settings',JSON.stringify(settings))},/** 設定を読み込みます */loadSettings(state){constsettings=JSON.parse(localStorage.getItem('settings'))if(settings){state.settings=Object.assign(state.settings,settings)}const{appName,apiUrl,authToken}=state.settingsdocument.title=appNamegasApi.setUrl(apiUrl)gasApi.setAuthToken(authToken)}}/** * Actions * 画面から呼ばれ、Mutationをコミットします */constactions={/** 指定年月の家計簿データを取得します */asyncfetchAbData({commit},{yearMonth}){consttype='fetch'commit('setLoading',{type,v:true})try{constres=awaitgasApi.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 にローディング状態とエラーメッセージを追加します。
importgasApifrom'../api/gasApi'/** ローディング状態 */loading:{fetch:false,add:false,update:false,delete:false},/** エラーメッセージ */errorMessage:'',saveSettings,loadSettings 内でアプリ設定の apiUrl, authToken をgasApi に反映させます。
const{appName,apiUrl,authToken}=state.settingsdocument.title=appNamegasApi.setUrl(apiUrl)gasApi.setAuthToken(authToken)Actions の中でクライアントを使ってリクエストを送信します。
/** 指定年月の家計簿データを取得します */asyncfetchAbData({commit},{yearMonth}){consttype='fetch'// 取得の前にローディングをtrueにするcommit('setLoading',{type,v:true})try{// APIにリクエスト送信constres=awaitgasApi.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 を呼んでいた箇所も変更が必要なので、対応させます。
exportdefault{name:'Home',/** ~ 省略 ~ */data(){consttoday=newDate()constyear=today.getFullYear()constmonth=('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:{/** ~ 省略 ~ *//** 表示させるデータを更新します */asyncupdateTable(){constyearMonth=this.yearMonthconstlist=this.abData[yearMonth]if(list){this.tableData=list}else{awaitthis.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 に直します。
asyncupdateTable(){/** ~ 省略 ~ */awaitthis.fetchAbData({yearMonth})/** ~ 省略 ~ */},このままだと通信でエラーが起きたときにメッセージが表示されないので、App.vue にエラーメッセージを表示させるようにします。
<template><v-app><!-- ~ 省略 ~ --><v-main><!-- ~ 省略 ~ --></v-main><!-- スナックバー --><v-snackbarv-model="snackbar"color="error">{{errorMessage}}</v-snackbar></v-app></template><script>import{mapState}from'vuex'exportdefault{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 との内容を書き換えます。
/** ~ 省略 ~ */constactions={/** 指定年月の家計簿データを取得します */asyncfetchAbData({commit},{yearMonth}){/** ~ 省略 ~ */},/** データを追加します */asyncaddAbData({commit},{item}){consttype='add'commit('setLoading',{type,v:true})try{constres=awaitgasApi.add(item)commit('addAbData',{item:res.data})}catch(e){commit('setErrorMessage',{message:e})}finally{commit('setLoading',{type,v:false})}},/** データを更新します */asyncupdateAbData({commit},{beforeYM,item}){consttype='update'constyearMonth=item.date.slice(0,7)commit('setLoading',{type,v:true})try{constres=awaitgasApi.update(beforeYM,item)if(yearMonth===beforeYM){commit('updateAbData',{yearMonth,item})return}constid=item.idcommit('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'exportdefault{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:{/** ~ 省略 ~ *//** 追加/更新がクリックされたとき */asynconClickAction(){constitem={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||0if(this.actionType==='add'){awaitthis.addAbData({item})}else{item.id=this.idawaitthis.updateAbData({beforeYM:this.beforeYM,item})}this.show=false},/** ~ 省略 ~ */}}追加も編集も同じコンポーネントで行っているので、
どちらかが実行中であれば loading が true となるようにします。
...mapState({/** ローディング状態 */loading:state=>state.loading.add||state.loading.update}),追加/編集がダイアログから実行できるか確認してみます!
どちらも実行できればOKです!スプレッドシートも確認してみてください。
API で削除できるようにする
最後に、DeleteDialog から API を使って削除できるようにします。
/** ~ 省略 ~ */constactions={/** 指定年月の家計簿データを取得します */asyncfetchAbData({commit},{yearMonth}){/** ~ 省略 ~ */},/** データを追加します */asyncaddAbData({commit},{item}){/** ~ 省略 ~ */},/** データを更新します */asyncupdateAbData({commit},{beforeYM,item}){/** ~ 省略 ~ */},/** データを削除します */asyncdeleteAbData({commit},{item}){consttype='delete'constyearMonth=item.date.slice(0,7)constid=item.idcommit('setLoading',{type,v:true})try{awaitgasApi.delete(yearMonth,id)commit('deleteAbData',{yearMonth,id})}catch(e){commit('setErrorMessage',{message:e})}finally{commit('setLoading',{type,v:false})}},/** ~ 省略 ~ */}/** ~ 省略 ~ *//** ~ 省略 ~ */import{mapActions,mapState}from'vuex'exportdefault{name:'DeleteDialog',data(){return{/** ダイアログの表示状態 */show:false,/** 受け取ったデータ */item:{}}},computed:mapState({/** ローディング状態 */loading:state=>state.loading.delete}),methods:{/** ~ 省略 ~ *//** 削除がクリックされたとき */asynconClickDelete(){awaitthis.deleteAbData({item:this.item})this.show=false}}}削除がダイアログから実行できるか確認してみます!
実行できればOKです!スプレッドシートも確認してみてください。
ハンズオンは以上になります。お疲れ様でした!

ホーム画面で収支の総計を確認できるようにしたり、毎月1日に先月の収入を自動で繰り越す GAS プログラムを追加したり…。
フロントに限らず、GAS 側も自分好みにしてみてください!
ハンズオン完成時点のソースコード一覧はこちらから確認できます!
さいごに
Vue.js の勉強用に作成したものなので、
改善できるところなどありましたらコメントで教えていただけると嬉しいです!
ハンズオンを最後まで進めていただいた方、上から飛んできた方も
最後まで閲覧いただきありがとうございました!
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme



























