Google Apps ScriptでVuetify v2を使ってUIを作る【GAS】
Google Apps Scriptは、HTML ServiceでウェブアプリケーションやスプレッドシートのダイアログのUIを構築可能です。しかし、標準ではUIを構築する為のライブラリやフレームワークを備えていないので、ユーザ自身が好きなフレームワークを導入して、HTML上に構築する必要があります。
自分は過去に、Google Apps Scriptのアプリをスマフォ向けとしては、framework7を使ってUIを構築しました。いずれ、framework7のまとめも作ってみたいと思います。今回はPC向けサイトとしてVuetifyを使って構築してみたいと思います。随時、自分がパーツを使うシーンがあれば、ここに追記していきたい。
※2023年12月31日をもってVue.js v2がサポートアウトする関係で、Vuetifyについてもv3移行が促されいます。基本的な使い方は同じですが、変更されてる部分もあるため、v3については以下のエントリーを参考にv2から移植を行ってください。
図:現在作成中のElectronアプリでも使ってます
目次
- 1 今回使用するライブラリ等
- 2 事前準備
- 3 色々なコントロールを使ってみる
- 3.1 Cards
- 3.2 Snackbar
- 3.3 selectbox
- 3.4 Checkbox
- 3.5 Appbar
- 3.6 Bottom Sheet
- 3.7 Dialog
- 3.8 Carousel
- 3.9 Navigation drawers
- 3.10 Autocomplete
- 3.11 Simple Table
- 3.12 Table
- 3.13 Validation
- 3.14 Expansion Panel
- 3.15 List
- 3.16 Chips
- 4 組み合わせの妙技
- 4.1 Expantion Panel内のリストをv-forで作る
- 4.2 テキストボックスとカレンダー連動+α
- 4.3 テキストボックスとタイムピッカー連動+α
- 4.4 タブとツールバーの連携技
- 4.5 Vue.jsのv-forとv-ifとの連携技
- 4.6 Cheetah Grid
- 5 関連リンク
今回使用するライブラリ等
- Vuetify - A Material Design Framework for Vue.js
- サンプルスプレッドシート
- MaterialDesignIcons.css Version 7.2.96
VuetifyはVue.jsのUIフレームワークになるので(ちょうど、jQueryとjQuery UIの関係になる)、Vue.jsも利用する必要があります。
※ダミーデータはOnline test data generator等で作成したものを利用しています。
※MaterialDesignIconsの検索はこちらのサイトを利用しています。
事前準備
ウェブ上で公開されてる事前準備では、vueファイルの作成であったり、vue-cliを使うためにnpmでインストールといったような説明書きをよく見かけますが、そもそもjQuery同様にvue.jsやvuetifyは単独で利用出来るようになっているので、あの手順にしたがってプロジェクトを作る必要性はありません。また、Google Apps Scriptでは当然vueファイルやらnpmでインストールなんてできません。
Google Apps ScriptではいくつかのCDNで公開されてるライブラリをロードすることで、コード内で利用できるようになります。こちらのページのUsage with CDNがそれに該当します。
Headerタグ内でCSSをロード
1 2 3 4 5 |
<head> <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet"> </head> |
- Header部分ではCSSを読み込みます。
- 他にもGoogle FontsやViewportの設定も指定が必要になります。
Bodyタグ内の一番下でJSをロード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<body> ・・・・前略・・・・ <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script> <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script> <!-- Vuetifyを初期化する --> <script> new Vue({ el: '#app', vuetify: new Vuetify(), }) </script> </body> |
- Vue.jsやVuetify.jsといったライブラリのJSファイルは、Header内で読み込ませても、エラーになります
- Bodyの一番最後で読み込ませ、また初期化の為のスクリプトも同じ場所で記述する必要があります。
- new Vue内にて初期化する際に、vuetifyを指定し、そこでnew Vuetifyを呼び出すことで、Vuetifyを初期化しています
viewportの指定
今回はPC向けということなのですが、モバイル向けとしてviewportの指定が必要な場合があります。しかし、通常通りHTML内に記述しても、Google Apps Scriptの場合それらのタグは無視されてしまいます。これらは、HTML Serviceの引数として、addMetaTagでの指定が必要です。faviconなども同様です。
※faviconを指定するとウェブアプリのアイコンが指定のアイコンになります(サンプル)
1 2 3 4 5 6 7 8 9 10 11 12 |
function doGet(e){ //パラメータを取得する var param = e.parameter.param1; //paramを元にHTMLをレンダリング var html = HtmlService.createHtmlOutputFromFile(param) .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) .setFaviconUrl('https://drive.google.com/uc?id=1rPShWER6M-z0IxcVSFa6Cx1MLPE-6MCf&.png') .addMetaTag('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui'); return html; } |
色々なコントロールを使ってみる
Vuetifyのサイトには様々なUIコントロールのサンプルが掲載されています。しかし、これらのコードをそのままGoogle Apps ScriptのHTML Serviceで動かそうとした場合、動かないケースがままあります。今回はいくつかのコントロールをテストしてみて、実際にこれなら動くという形で、ちょっとだけ変えてあります。
非常に沢山のコントロールと、それぞれにオプションやテクニック、APIが用意されているので、表現力豊かなアプリケーションが構築可能です。
以下のコードはHeaderやBody下部の共通部分については省略しています。
Cards
地味によく使う、タイルのように扱えるコンポーネントです。他にもレイアウト関係のコンポーネントはたくさんあるのですが、その中では随一の使用率。それがこのCardsです。ダイアログであったり、Amazonの商品一覧のように並べたり、細かいテクニックも必要です。
Cardsそのものは他のコンポーネントのように特別スクリプトを必要としないのですが、Cards内でのボタンなどで必要とするケースが多々あります。今回は縦にCardsを掲示板のスレッドのように列挙してみます。
ソースコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<v-container> <v-card> <v-card-title class="headline grey lighten-2"> Privacy Policy </v-card-title> <v-card-text> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. </v-card-text> <v-divider></v-divider> <v-card-actions> <v-spacer></v-spacer> <v-btn color="primary" text @click="dialog = false" > I accept </v-btn> </v-card-actions> </v-card> </v-container> |
- これで1枚分のCardsの表示になります。
- v-containerで括れば、v-cardを縦に列挙した場合、Cards同士の間にきちんと隙間が出来てそれっぽく表示されます。ない場合は密着してしまいます。
- v-dividerで区切り線を表記します。
- v-card-text内にメインコンテンツを記述します。
- v-card-actionsがボタンなどを表記します。
- v-cardのレイアウトの作り方は多種多彩なので、様々なパターンを会得すると表現力がアップします。
- データをもとにCardsを自動生成する場合(v-forなど)やボタンのアクションは別途スクリプトが必要です。
図:Cardsを縦に並べてみた
きれいに並べてみる
カードをきれいにタイル状に並べるには、地味にテクニックが必要です。普通にv-rowとv-colで並べてもきれいに並ぶこともないですし、ウィンドウのリサイズ時に合わせてレスポンシブに横のタイル数が変化する事もありません。そこで、これらをきれいに並べるには以下のようなHTMLとCSSでの調整が必要になります。
CSS側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/* vuetifyのcontainerクラスのCSSをリセット */ .container { margin-right: unset !important; margin-left: unset !important; max-width: unset !important; } /* vuetifyのレスポンシブタイル配置の調整用 */ .col { padding: unset !important; padding-bottom : 5px !important; } .box_srcollbar2 { overflow:auto; width:100%; } |
- Vuetify標準のCSSですと、タイル間の隙間が広がりすぎてたりするので、これを詰めたり、タイルエリアのスクロールエリアの為のCSS調整が必要です。
HTML側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<!-- 作業項目を列挙する --> <div id="gridarea2" style="width: 100%;" v-show="acpanel == 1" v-resize="updateDataTableHeight"> <div class="box_srcollbar2" :height="dataTableHeight" id="boxman2"> <v-container> <v-row cols=12 sm=10 md=8 lg=4 xl=3 justify="center" align-content="center"> <v-col v-for="(item, i) in items" :key="i"> <v-card outlined tile hover height=300 width=350 class="d-flex flex-column" height="100%"> <v-img :src="item.imgurl" height="50px" ></v-img> <v-card-title class="titlehead2" v-text="item.hospname"></v-card-title> <v-card-text v-text="item.description"></v-card-text> <v-spacer></v-spacer> <v-card-actions> <v-card-text v-text="item.area"></v-card-text> <v-spacer></v-spacer> <v-btn class="ma-2 white--text blue" @Click="select($event)" elevation="2" > 開く </v-btn> </v-card-actions> </v-card> </v-col> </v-row> </v-container> </div> </div> |
- box_scrollbar2にて、div内にスクロールバーを追加しています。
- リサイズ時にbox_scrollbar2エリアの縦横サイズをフィットするようにupdateDataTableheight関数をv-resizeで呼び出しています
- v-rowでは画面のサイズ毎に1行に並べるタイル数を指定しています(リサイズ時にこれに応じてタイルの数が変わる)
- v-colでは、itemsに格納されたリストデータに応じてv-cardをレンダリングさせています。
- v-cardではclass指定でd-flex, flex-columnが必要です。また、オプション指定でtileやoutlinedも必須
- あとはv-cardの中身を記述していく。縦横のサイズとv-rowのタイル数は関係してくるので、注意
- v-cardにflatプロパティを追加すると、縁取りが消えます。レイアウト構築の場合縁取りは邪魔だったりするので付けておくと良いでしょう。
JS側
1 2 3 4 5 6 7 8 |
//リサイズ時にdatatableの高さを変更する updateDataTableHeight() { //simple-tableの高さを動的変更 var layoutHeight = $(window).height() - 100; this.dataTableHeight = layoutHeight; $('#gridarea2').css('height', layoutHeight + 'px'); $('#boxman2').css('height', layoutHeight2 + 'px'); } |
- リサイズ時に呼び出されるbox_scrollbar2とgridarea2のサイズをウィンドウに合わせるコード
図:こんな感じにレスポンシブに並べる
Snackbar
一般には、Toastと呼ばれてる短いメッセージを画面の下部などにフワっと表示させる為のもの。いちいちalertなどのダイアログを出すまでもないような「作業完了」や「データ無し」などの簡易なメッセージはこれらを使って実現すると、ユーザがいちいちOKボタンクリックといった鬱陶しい作業をしなくてすみます。スマフォなどでは結構おなじみの機能ですね。
ソースコード
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<!-- メッセージ Toast--> <v-snackbar v-model="snackbar" :timeout="timeout" > {{ text }} <template v-slot:action="{ attrs }"> <v-btn color="blue" text v-bind="attrs" @click="snackbar = false" > 閉じる </v-btn> </template> </v-snackbar> |
- v-modelにて連結してるsnackbarの値がtrueの時に表示されます。
- :timeoutにて自動で消える秒数(ミリセカンド)を指定しています。
- 閉じる為のボタンもつけていますが、タイムアウト指定で自動で消えるようにもしています。
JS側コード
1 2 3 4 5 6 7 8 9 |
vm = new Vue({ el: '#app', vuetify: new Vuetify(), data: () => ({ snackbar: false, text: 'データがありませんでした', timeout: 3000, }), }); |
- テキストはtextの値を変更すれば変える事が可能です。(vm.text = "とまと食べたい"などで変更可能)
- timeoutの秒数は3000msを自分は指定しています。
- timeoutは-1を指定すると自動的に閉じなくなります。
実際に表示する場合は、上記のコードであれば、vm.snackbar = trueで表示、vm.snackbar = falseで非表示となります。指定秒数経過後には自動でfalseになります。
図:画面下部にフワっと表示されフワっと消える
注意点
fullscreenなDialogなどを表示してるケースで、snackbarを表示し、snackbarの閉じるボタンをクリックすると、Dialogのほうまで閉じてしまうバグ?のようなケースがあります。この問題の回避方法はStackOverflowに投稿されていましたが、ダイアログ側のプロパティとして「persistent」を追加する事で、snackbarの閉じるボタンをクリックしても、Dialogまで閉じられてしまうような事を回避する事が出来ます。
これは、snackbarがDialogの外側に位置してる為、クリックするとDialogの外側をクリックされたと判定され、Dialogが自動で閉じてしまう問題で、persistentを追加する事で、ダイアログの外側をクリックされても、閉じるのを阻止してくれる為、特にfullscreenなダイアログとsnackbarを併用する時には必須となります。
1 2 3 4 5 |
<v-dialog v-model="dialog" fullscreen hide-overlay persistent transition="dialog-bottom-transition"> //ここにダイアログの内容を記述する </v-dialog> |
selectbox
UIパーツの中でももっとも基本的なパーツがv-selectタグを使ったコンボボックス or ドロップダウンリストです。このリストは表示する値とは別に反映する値を別に設定出来ますし、複数列を持つJSONの値をセットして運用することもできるのですが、Vuetifyのバージョンによるものなのか、不可解な動きをすることもあるので注意です。
ソースコード
HTML側コード
1 2 3 4 5 6 7 8 9 10 |
<v-select :items="kaigai" item-text="funin" v-model="selerec.funinsaki" item-value="ID" label="出張先" hint="企業名を選択" v-on:change="funinchg" persistent-hint return-object> </v-select> |
- v-modelで入力先の変数を指定しています(今回は取得レコードのfuninsakiという値に入れています)
- 選択時にv-on:changeにてfuninchg関数を実行するようにしています。後述のJSONの他の値を自動的に別のv-text-fieldに入れるようにしています。
- 表示するのはJSON値のfunin列を指定するために、item-textにfuninを指定しています。
- 格納する値はJSON値のID列を指定する為に、item-valueにIDを指定しています。
- 上記のtextとvalueの指定がない場合、JSONのtextとvalueの項目がデフォルトで選択されるようになっています。
- :itemsのkaigaiがJSONデータを格納してる先。Vue初期化時のdataの中に予めkaigaiと次項のJSONを格納しておく必要があります。
今回割当てたメニュー用JSON
1 2 3 4 5 6 |
[ {ID:1,funin:'米国'}, {ID:2,funin:'英国'}, {ID:3,funin:'ベトナム'}, {ID:4,funin:'インドネシア'} ] |
非常に単純な、ID列とfunin列の2つのカラムで構成されています。funinchg関数では、セレクトされるとfunin列の値を別のtext fieldに入れるようにしています。
JS側コード
1 2 3 4 5 6 |
methods: { funinchg(e){ //赴任先を選んだら関連項目を他のエリアに代入する this.selerec.funin_country = e.funin; }, } |
- vueのmethodsにfuninchg関数を用意。
- eでは選択した項目のJSON値が全部入ってるので、ここからfuninの値を取り出します。
- 取り出した値は直接選択レコードのJSONに格納すれば、v-modelで連結してるv-text-fieldに表示されます。
- この手法は、HTML側でreturn-objectを指定しておく必要があります(そうでないと、選択した項目のJSON値が取得できない)
注意点
値がobjectで入ってしまった
Vuetifyのv2で遭遇したのですが、このreturn-objectを指定してる場合、またそのv-selectが既存の変数にv-modelで連結してるケースで、item-valueやitem-textで列指定しているにも関わらず、v-selectのv-modelで連結してる項目にitem-valueの値ではなく、選択した項目のJSON値が[object,object]として入ってきてしまうケースがありました。
return-objectを外せばこのような問題は起きない反面、funinchg関数で選択したJSON値を取得できなくなってしまい、連動して他のtext-fieldに値を入れ込むことができなくなりました。なぜこのような事象が発生したのかは不明ですが、連動をさせないケースではreturn-objectは外しておきましょう。この問題は解決策が無いので、変数名変えたりv-modelでselectには連結しないで非連結とし、2つのtext-fieldに入れるようにするなどの迂回策が必要です。
特定の値が出てこない
v-selectにて、grouparrという選択肢のJSONデータを格納してる際に、確かに格納されてるハズの特定の値が選択肢に出てこないケースがありました。格納されてる値は以下のような形
1 2 3 4 |
[ {"id":1, "busyo":"空手部","grouparr":"karate@test.com"} {"id":2, "busyo":"帰宅部","grouparr":"kitaku@test.com"} ] |
この値を取得してgrouparrという変数に格納しました。しかし、v-selectで以下のような形で記述をした際に、上記の項目のgrouparrのメアドが同じものがあった場合、そのどちらかが選択肢に現れないという現象に遭遇。
1 2 3 4 5 6 7 8 9 10 11 12 |
<v-select :items="grouparr" item-text="busyo" item-value="grouparr" v-model="affiliation" label="所属部署" hint="所属部署を選択" persistent-hint return-object :rules="[rules.required]" v-on:change="funinchg" ></v-select> |
上記のitem-valueの項目を除外し、v-on:changeにて選択した値をaffiliationに格納する形に修正したところ、問題なく表示が出来ました。おそらく変数名のgrouparrと要素名が同じgrouparrであったが為に発生した問題だと思いますが、要素のgrouparrが異なる値の場合はしっかり選択肢に出てくるので、重複した値をつけてると排除されてしまうのだと思います。
故にitem-valueをなくしてv-on:changeのfuninchg関数で迂回し、affiliationに格納することで正しい挙動にすることが出来ました。
Checkbox
GUIコンポーネントの中では最も基本的な項目であるCheckboxですが、VuetifyのCheckboxはオンオフや値の取り方・適用方法がちょっと普通とは異なる為、嵌りポイントが多いです。
ソースコード
1 2 3 4 5 6 7 8 9 10 |
<div id="app"> <v-app> <v-checkbox label="正社員" color="green" v-model="checked1" hide-details ></v-checkbox> </v-app> </div> |
- labelにチェックボックスのラベル文字を入れます
- colorでチェックボックスの色を指定する事が可能です。
- 現在、v-checkboxはv-modelで変数と連結し、true/falseでオンオフが出来るようになっています。
v-modelや:valueではなく、:input-valueでvueの変数と連結する必要があります。値のオンオフは、1がオン、0がオフとなるため、true/falseでコントロールが出来ません。
Appbar
アプリケーションの顔とも言える、アプリ上部にあるヘッダに該当するナビゲーションバーです。この他にもファイルやヘルプなどのツールバーなどもあったりします(もちろん、モバイル向けの下部ナビゲーションも)。ただし、サンプルのままで動作しようとすると、このナビゲーションバーにコンテンツ部分が被ったりと色々と面倒な部分もありましたが、以下のコードで動作させることが可能です。
ソースコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
<div id="app"> <v-app> <v-app-bar color="light-blue lighten-5" app color="#fcb69f" shrink-on-scroll scroll-target="#main_content"> <v-app-bar-nav-icon></v-app-bar-nav-icon> <v-toolbar-title class="ml-2"> 南国フルーツ図鑑 </v-toolbar-title> <v-spacer></v-spacer> <v-btn icon> <v-icon>mdi-magnify</v-icon> </v-btn> <v-btn icon> <v-icon>mdi-heart</v-icon> </v-btn> <v-btn icon> <v-icon>mdi-dots-vertical</v-icon> </v-btn> </v-app-bar> <v-sheet id="main_content" class="overflow-y-auto" max-height="600"> <v-main class="text-center ma-6" style="height: 1500px;"> ここにコンテンツを記述する </v-main> </v-sheet> </v-app> </div> |
- v-app-barにおいて、absoluteの指定があると下のコンテンツ部分に被ってしまうので省略
- shrink-on-scrollの指定がある場合、スクロールするとバーが自動的に半分くらいのサイズにミニマムになる
- scroll-targetの指定がある場合、スクロールするエリアは指定したID(今回だとmain_content を持つv-sheet)が対象
- v-btnがナビゲーションのボタンの部分を担当する
- v-app-bar-nav-iconは左サイドのハンバーガーメニューです
Bottom Sheet
スマフォなどではおなじみのボタンをクリックすると、下からメッセージがせり上がってくるアレです。アプリで使うシーンとなると、普通のalertの代わりという、承認時のメッセージであったりといったダイアログよりも目立たせたいのが、使うポイントになるかと思います。
ソースコード
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<script> new Vue({ el: '#app', vuetify: new Vuetify(), data: () => ({ sheet: false, }), methods:{ onApprove(){ alert("承認しました"); this.sheet = false; //BottomSheetを閉じる }, onReject(){ alert("却下しました"); this.sheet = false; //BottomSheetを閉じる }, } }) </script> |
- dataの中のsheetをフラグとし、falseで閉じます
- 承認と却下用のコマンドをmethodsに加えて起きます。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<v-app id="app"> <v-container> <v-row class="text-center"> <v-col col="12"> <div class="text-center"> <v-main class="text-center ma-6"> ここにコンテンツを記述する </v-main> <v-bottom-sheet v-model="sheet" inset> <!-- ボタンを表示する --> <template v-slot:activator="{ on, attrs }"> <v-btn color="blue" dark v-bind="attrs" v-on="on">ボトムを開く</v-btn> </template> <!-- ボトムシートの中身 --> <v-sheet class="text-center" height="200px"> <v-btn class="mt-6" text color="error" @Click="onApprove">承認</v-btn> <v-btn class="mt-6" text color="error" @Click="onReject">却下</v-btn> <div class="my-3">この申請を処理してください。</div> </v-sheet> </v-bottom-sheet> </div> </v-col> </v-row> </v-container> </v-app> |
- ボトムを開くをクリックすると、ボトムシートが開かれます
- 承認、却下はそれぞれがmethodsに規定したコマンドを@Clickにて実行し、this.sheet=falseで閉じるようになっています。
Dialog
アプリケーションで非常によく使う要素の1つがダイアログでしょう。jQueryなどを使わずにオシャレなダイアログを構築する事が可能です。その表現の数も非常に充実しており、よくあるYES/NO、フルサイズの設定用ダイアログ、ダイアログのネスト、プログレス表示用などなど、ユーザの要求を適切に受け取ることが可能です。
ソースコード
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
new Vue({ el: '#app', vuetify: new Vuetify(), data () { return { dialog: false, } }, methods:{ onApprove(){ alert("承認しました"); this.dialog = false; //dialogを閉じる }, onReject(){ alert("却下しました"); this.dialog = false; //dialogを閉じる }, } }) |
- コード自体はBottom Sheetとほとんど同じものになります。
- dataの中のdialogをフラグにして、falseの時に閉じます。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
<v-app id="app"> <v-row class="text-center"> <v-dialog v-model="dialog" persistent max-width="290"> <!-- 承認ボタン --> <template v-slot:activator="{ on, attrs }"> <v-btn color="primary" dark v-bind="attrs" v-on="on"> 承認実行 </v-btn> </template> <!-- カードで整形する --> <v-card> <v-card-title class="headline"> この申請を承認しますか? </v-card-title> <v-card-text>承認を実行すると、本人にもメールで結果が通知され、申請書が発行されます</v-card-text> <v-card-actions> <v-spacer></v-spacer> <v-btn color="blue darken-1" text @click="onApprove"> 実行 </v-btn> <v-btn color="red darken-1" text @click="onReject"> キャンセル </v-btn> </v-card-actions> </v-card> </v-dialog> </v-row> </v-app> |
- 承認実行ボタンを押すとダイアログが表示されます。
- ダイアログの2つのボタンにそれぞれ@Clickで実行するmethodを指定しておきます。
注意点
フォーカスが奪われて入力できなくなる
ElectronでVuetifyを使い、ダイアログを使っていた際に遭遇したトラブルなのですが、保存ボタンを押しthis.dialog = falseをした後に、confirmにて選択のメッセージを出した場合以下のトラブルが生じました。
- 保存は実行され、再度ダイアログも開けるが、テキストボックスをクリックしてもフォーカスされず、入力ができなくなる
- ただし、カレンダーやセレクトボックスは正常に動作する
- 一度デスクトップをクリックして、再度Windowをクリックするとフォーカスして入力ができるようになる
- しかし、一時的に治ってるだけで、再度Dialogを閉じると同様の症状が発生する
回避策としては、Electronであるならば、メインプロセス側でdialog.messageにて表示して分岐処理をさせるようにする。Google Apps Scriptの場合は、confirmを使わず別の確認メッセージを表示する手段を利用するようにしましょう。
また、似たような症状で、CKEditorなどで表示したダイアログを合わせ技で使うとテキストボックスにフォーカスできなくなる現象もあります。回避策は以下のエントリーを参考にしてみてください。
Google Sitesとの相性が悪い
上記と同じくFullscreenなDialogを表示してる場合、通常のウェブアプリとして表示した場合は特に問題なく表示できるのですが、このウェブアプリに対してtransitionを「dialog-bottom-transition」としてる場合、Google Sitesにフルスクリーンで貼り付けると異様にダイアログの領域が小さく表示される現象が発生しています。また、フルスクリーン時にツールバーを加えているのですが、これも領域外にはみ出してしまい操作が出来なくなる。
このような場合はtransition指定を外して以下のようにすると現象が発生しなくなります。
1 2 3 4 |
<v-dialog v-model="dialog5" fullscreen persistent :retain-focus="false" no-click-animation> </dialog> |
これは、CKEditorでフォーカスが勝手にダイアログを奪われるというケースでも同様の手法を使っています。Google Sitesに貼り付けた場合にだけ起きる現象なので非常に特殊な事例ですが、transitionは外しておくのがよさそうです。
Carousel
ブログパーツであったり、スマフォのアプリであったり、最近は様々なシーンで使われるようになったUIコントロールが「カルーセルスライダー」。主に写真であったり、プレゼンのスライドのようなものであったりに使われていますが、いざ自前で実装すると、結構大変です。このカルーセルをVuetifyは簡単に実装できるだけでなく、配列に値をpushするだけで自動で追加項目が反映するVue.jsのBindの特性が活かせるコントロールでもあります。
ソースコード
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
var calitem = [ { src: 'https://drive.google.com/uc?id=1pAuuo3qeFilFciiF6dCYP7_hrbxU8avZ&.jpg' }, { src: 'https://drive.google.com/uc?id=1lUdAgnko7q0gMmMqWghrI3xIh1xrOP4T&.jpg' }, { src: 'https://drive.google.com/uc?id=1SrJo8FmqM32TlVIuXwsJENqa2DLu2_Tv&.jpg' }, { src: 'https://drive.google.com/uc?id=15EOT__Zj7od8inQKLMk-5vixcOa_SgWz&.jpg' }, { src: 'https://drive.google.com/uc?id=13IifvE4R8Wnwc_OmCfoJ9lJWfbysZ_cy&.jpg' } ]; new Vue({ el: '#app', vuetify: new Vuetify(), data () { return { items: calitem } }, methods:{ addPicture(){ calitem.push({src:'https://drive.google.com/uc?id=1cBWBxtb3pXat_4zDI5vbBQOsU5U3llj1&.jpg'}) console.log(calitem); } } }) |
- calitemという配列を格納してる変数に、連想配列でsrcを指定したデータを格納しておく
- Vue.jsにてdataのitemsにcalitemを指定するとカルーセルスライダー用のデータとして利用される
- addPictureメソッドにて、この配列に新たな画像データを動的に1個追加する
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 |
<v-app id="app"> <template> <v-btn block @Click="addPicture"> 画像追加 </v-btn> </template> <v-carousel> <v-carousel-item v-for="(item,i) in items" :key="i" :src="item.src" reverse-transition="fade" transition="fade"> </v-carousel-item> </v-carousel> </v-app> |
- v-carouselにて、itemsの中身をv-forでループ、バインドされたデータをカルーセルで表示するようになっています。
- 画像が追加されると自動的にVue.jsにてそれらは動的に取り込まれすぐ反映してくれる。jQueryのようにリロードといったコードは不要となる。
- サンプルでは、クリックすると、一番最後にハイビスカスの画像がカルーセルに追加されます。
いわゆる、サイドバーのこと。例えば、ハンバーガーメニューをクリックすると左側から選択用のメニューが出てくるようなアレです。現代のアプリケーションを構築し、ナビゲーションをするには必須の項目ですね。MenusやLists、TreeViewを組み合わせてれば、リッチなUIを実現出来ます。
ソースコード
JS側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
new Vue({ el: '#app', vuetify: new Vuetify(), data:()=> ({ drawer: false, group: null, items: [ ['mdi-email', 'Mail','メール開くよ'], ['mdi-account-supervisor-circle', 'Member', 'メンバー一覧'], ['mdi-clock-start', 'Task', '予定タスク'], ], }), watch: { group () { this.drawer = false }, }, methods: { onListChange(event){ targetId = event.currentTarget.id; alert(targetId); } } }) |
- Drawerの開閉フラグは変数drawerが担当しています。
- サイドバーの中身用のリストは、itemsの値から生成しています。
- リストをクリックしたら発火する関数として、onListChangeを定義していて、そのタグ内のIDの値をalertで表示
- onListChangeで引数で受けるIDはitemsで生成時にv-bindで動的に割り当てたものを利用しています。
HTML側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
<v-app id="app"> <v-card class="mx-auto overflow-hidden" height="500" width="100%"> <v-system-bar color="deep-purple darken-3"></v-system-bar> <!-- アプリのタイトル --> <v-app-bar color="deep-purple accent-4" dark prominent> <v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon> <v-toolbar-title>タスク管理 山猫さん</v-toolbar-title> </v-app-bar> <!-- Drawer部分 --> <v-navigation-drawer v-model="drawer" dark absolute temporary src="https://cdn.vuetifyjs.com/images/backgrounds/bg-2.jpg"> <v-list nav dense> <v-list-item-group v-model="group" active-class="deep-purple--text text--accent-4"> <v-list-item v-for="([icon, text,args], i) in items" :key="i" link v-on:click="onListChange($event)" v-bind:id="args"> <v-list-item-icon> <v-icon>{{ icon }}</v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title>{{ text }}</v-list-item-title> </v-list-item-content> </v-list-item> </v-list-item-group> </v-list> </v-navigation-drawer> <v-card-text> ここにコンテンツを記述する </v-card-text> </v-card> </v-app> |
- v-navigation-drawerにbottomを指定すると、サイドバーではなくアンダーバーになります。
- v-list-itemではv-forにてitemsから値を取得し、v-list-itemを生成しています。
- v-on:clickにてリストアイテムクリック時にonListChangeを実行しています。引数に$eventを指定すると、他の属性値(例えばidなど)を関数側で取得できます。
- v-bindにて、itemsのargsをIDとして割り当ててます。
- v-list-itemに対して@Clickでコマンドを指定することで選択時の挙動を制御出来ます。其の際に自動的にDrawerを自動で閉じるには以下のコードをコマンドに追加しておく必要があります。
1234setTimeout(() => {//ドロワーをオフにするthis.drawer = false}, 0) - itemsのiconはMaterial iconの文字列を指定、textはリストのタイトル。それぞれをVue.jsで割当
- v-list-item-groupにv-modelでgroupを指定していますが、group(ドロワーの選択項目)は上から0〜を割り振られているので、groupの値を変更することでアクティブ(選択状態)にすることが可能です。
Autocomplete
少ない選択肢ならば、コンボボックスなどのv-selectを使って表現をすれば良いのですが、非常に多くの選択肢がある場合には選択肢が逆に利便性を損ねてしまいます。そこで利用するのが、オートコンプリート。ユーザの入力ワードでGoogleのサジェストのように機能するものです。
しかし、公式サイトにあるサンプルコードだと、単純な一次元配列の選択肢を表示するだけで、業務の現場で必要とされる
- 選択肢の項目に複数の関連情報を表示する(社員番号と社員名など)
- 選択したら、そのデータの塊を取得して、他の入力項目に流し込みたい(選択したレコードの取得)
- 選択肢データはMySQLやSQLiteに格納済みデータをそのまま利用したい(JSONデータで選択肢にそのまま利用する)
- 検索時は社員IDのみだけでなく、全レコードの列項目も検索対象にしたい。
といったものが満たせません。これらの要求を満たすようにAutoCompleteを実装してみたいと思います。1000以上ある選択肢でもユーザが絞り込んで選択できるスグレモノです。
ソースコード
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
<v-app> <v-form ref="form"> <v-row justify="center"> <v-card> <v-card-text> <v-container> <v-row> <v-col cols="12"> <v-autocomplete @change='seleresult($event)' append-icon="mdi-magnify" :loading="loading" :items="items" :search-input.sync="search" v-model="select" no-filter flat hide-no-data hide-details return-object placeholder="検索文字を入力"> <template slot="selection" slot-scope="{ item, selected }"> {{item.ID}} </template> <template slot="item" slot-scope="{ item, tile }"> <v-card class="mx-auto" width="100%"> <v-list-item two-line> <v-list-item-content> <v-list-item-title>社員名:{{item.empname}}</v-list-item-title> <v-list-item-subtitle>社員場号:{{item.emp_id}} - 会社名:{{item.corpname}}</v-list-item-subtitle> </v-list-item-content> </v-list-item> </v-card> </template> </v-autocomplete> </v-col> </v-row> </v-container> </v-card-text> </v-card> </v-row> </v-form> </v-app> |
- AutocompleteのChangeイベントにはseleresult関数を指定。引数には$eventを指定してあると選択したJSONレコードがまるまる取得出来ます。
- 選択肢の項目はitemsという変数を指定しています(初期値は空っぽです)
- slotのselectionには、選択したレコードのうち、今回はIDの値を残すようにしています。
- slotのscopeには、絞り込み結果を表示するテンプレートを割り当ててます。v-cardで括らないと選択できないリストになってしまうので、注意。
- 今回は検索結果には、社員番号と社員名、会社名の3項目を1つのCardとして表示するようにしてます。
- $eventで取得したJSONデータの塊はそのまま、変数selerecに流し込みます。
- autocomplete以外のテキストボックスなどは、selerecのJSONにv-modalで連結しておきます。selerecにデータが入ってきた時点で自動的にテキストボックスに値が反映します。
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
vm4 = new Vue({ el: '#app4', vuetify: new Vuetify(), data: () => ({ dialog: false, notifications: false, sound: true, widgets: false, selerec: {}, common: common, items: [], search: null, select: null, loading: false, }), watch: { search (val) { console.log(val); if(val == "" || val == undefined){ //何もしない }else{ val && val !== this.select && this.querySelections(val) } }, }, methods: { querySelections(v) { setTimeout(() => { this.items = this.common.filter(e => { //JSONデータを配列にする var ret = JSON.stringify(e); //検索キーワードが配列に含まれているかチェック var includeman = ret.indexOf(v); //配列に含まれていたら、trueを返すとフィルタされた値がitemsに入る if(includeman > 0) return true; }) this.loading = false }, 500) }, seleresult(event){ //eventに一連の値が入ってるので、これをそのままselerecに入れる if(event == "" || event == undefined){ //何もしない }else{ this.selerec = event; } }, } }) |
- loading変数がAutocompleteの表示フラグを担当する変数です。
- common変数はJSON形式での社員マスタの全データが入ってくる検索先の塊になります。
- items変数がautocompleteと連結してるフィルタしたデータを格納する先になります。
- watchにて文字が入力されたことを感知し、searchメソッドを実行します。
- searchメソッドでは、querySelectionsメソッドに入力値を投げ入れます。
- querySelectionsメソッドでは、変数commonの値に対してフィルタを掛けます。eにはcommonの1レコードが入ってきてループのように処理が連続で行われます。
- eの値をJSON.stringifyで加工する事で、indexOfをしたときにレコードの全列を検索対象にする事が出来ます。
- indexOfで検索値が存在した場合には、0以上の値が返ってくるので、それをもってtrueとすると、そのレコードは選択肢に表示されます。
- 選択後にloadingをfalseにすることで、選択肢を非表示にしています。
- seleresultメソッドでは選択後に、選択した対象のレコードのJSONをまるごとselerecに入れています。こうすることで、他のテキストボックスなどに自動的に値が反映されます。
図:こんな感じで選択肢の表示をフルカスタマイズ出来る
Simple Table
次項にあるような高度なDataTableではなく、ずらずらっと列挙するタイプのコンポーネントがシンプルテーブルコンポーネントです。高さなどが指定できる上にヘッダー部分の表示を固定化もできるので、例えばAccessで言うところのサブフォームのような表記が可能になります。コンポーネント内でスクロールバーが表示されて(DIVBOXみたい)列挙されてる値を閲覧可能なので、シンプルながら中々使い勝手の良いパーツです。
ソースコード
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
//Body読み込み時に初期化する function initial() { //Vue.jsを初期化 vm = new Vue({ el: '#app', vuetify: new Vuetify(), data: () => ({ desserts: recdata, editedIndex: "", editedItem: "", }), methods: { deleteItem (item) { //配列から削除 this.editedIndex = this.desserts.indexOf(item) this.editedItem = Object.assign({}, item) this.desserts.splice(this.editedIndex, 1) this.$nextTick(() => { this.editedItem = Object.assign({}, this.defaultItem) this.editedIndex = -1 }) }, } }) } |
- シンプルテーブル自体に関しては特にメソッド等はなく、vueを初期化すれば使えます。
- 今回はレコード削除ボタンを用意してるので、deleteItem関数だけmethodsに加えています。
- 実際に現場で利用する場合は、新規に追加するコード、追加時に値のValidationや合計作業時間の合計などを計算するメソッドを用意します。
- recdataに対して値を追加したり削除することで、テーブル側の表記が変わります。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<v-simple-table fixed-header height="300px"> <template v-slot:default> <thead> <tr> <th class="text-left"> PJコード </th> <th class="text-left"> PJ名 </th> <th class="text-left"> 作業時間 </th> <th class="text-left"> アクション </th> </tr> </thead> <tbody> <tr v-for="item in desserts" :key="item.name"> <td>{{ item.pjcode }}</td> <td>{{ item.pjtitle }}</td> <td>{{ item.timeval }}</td> <td> <v-icon small class="mr-2" @click="deleteItem(item)"> mdi-delete </v-icon> </td> </tr> </tbody> </template> </v-simple-table> |
- fixed-headerにてタイトル行を固定化出来ます。スクロールしてもタイトル行は常に表示の状態になります。
- Data Tableと異なり内容はTableタグそのもの。なので、THEADやTR/TD、TBODYなどを記述して構築します。
- テーブル内容はデータの塊であるdesserts変数の中身をv-forにて取り出し、各セルにはめ込んでいく形になります。
- 今回はレコードを削除する為のdeleteItem関数を割り当てたボタンをアクション列に用意しています。
図:農作業タスクの時間を登録してみた
Table
jQuery系で自分がよく使ってるものに、「jQuery DataTables」という非常に優れたプラグインがあるのですが、これをVuetifyのTableコンポーネントで実現する事が可能です。また、Vue.jsであるので、データの入れ替え等にまつわる動作がとっても楽ちんで、jQueryのように更新だのなんだので独特のコードを書かずとも変数入れ替えるだけでOKなので、とっても便利です。
ソースコード
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
var vm; //レコードデータ var recdata = []; //Body読み込み時に初期化する function initial() { //Vue.jsを初期化 vm = new Vue({ el: '#app', vuetify: new Vuetify(), name: "table", data: () => ({ search: "", headers: [ { text: '社員番号', align: 'start', filterable: true, value: 'emp_id', }, { text: '氏名', value: 'empname' }, { text: '戸籍氏名', value: 'koseki' }, { text: '性別', value: 'seibetsu' }, { text: 'フリガナ', value: 'kana' }, { text: 'ローマ字', value: 'roma' }, { text: '入社日', value: 'join_date' }, { text: '社員区分', value: 'empkubun' }, { text: '採用地', value: 'saiyou' }, { text: '会社名', value: 'corpname' }, { text: '人事領域', value: 'corpcode' }, { text: '職種', value: 'jobname' }, { text: '部署', value: 'busyo' }, ], desserts: recdata, }) }) //現在の社員マスタをSQLiteから取得する google.script.run.withSuccessHandler(onSuccess).gettabledata(); } |
- headersでは、列名やソートの有無、フィルタ可能かどうかを追加できます。filterableでfalseを指定した列は、検索対象外になります。また、valueは連結するデータ(今回で言えば、dessertsがそれになる)のどの値を繋げるかを指定します。
- 初期化後にvm.dessertsに対して、JSON形式のデータを投げ込めば、値の入れ替えや追加が可能です。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
<v-app id="app"> <v-card> <v-toolbar dark prominent src="https://cdn.vuetifyjs.com/images/backgrounds/vbanner.jpg" class="fixed-bar"> <v-toolbar-title>社員マスタ</v-toolbar-title> <v-spacer></v-spacer> <v-btn icon dark text @click="importman"> <v-icon>mdi-import</v-icon> </v-btn> </v-toolbar> <v-card> <v-card-title> <v-text-field v-model="search" append-icon="mdi-magnify" label="検索" single-line hide-details ></v-text-field> </v-card-title> <v-data-table :headers="headers" :items="desserts" :search="search" sort-by="emp_id" :sort-desc="true" :footer-props="{ showFirstLastPage: true, firstIcon: 'mdi-arrow-collapse-left', lastIcon: 'mdi-arrow-collapse-right', prevIcon: 'mdi-minus', nextIcon: 'mdi-plus', items-per-page-text:'1ページ毎のレコード数' }" ></v-data-table> </v-card> </v-card> </v-app> |
- Table本体は、v-data-tableのタグの部分のみ。そこへ検索用のテキストフィールドを追加しているだけです。
- 標準だと日本語化されていない場所(items-per-page-textなどの部分)は、footer-propsの中で定義して置き換えることが可能です。Rows Per Pageを1ページ毎のレコード数に今回置き換えています。
- 移動用のアイコンも同様に標準のものから、マテリアルアイコンを指定して置き換える事が可能です。
- マルチソート、グルーピング、行選択などなど、幅広いオプションが用意されているので、気にいる形式に仕立て上げる事が可能です。社員マスタなどのマスタ系を弄るのに向いていると思います。
- sort-byで並べ替えをする列の項目を指定。emp_idというキー名で指定してる。
- この時同時に:sort-desc="true"を指定すると降順となる。
- widthのオプションがv-data-tableには無いため、外側にv-cardにwidthで指定して括ったり、v-colならばcolのsmの値などを調整して幅を指定する必要があります、
図:ダミーデータを10件ずつ表示してみた
常に全件表示とリサイズ対応
このTable機能非常に便利なのですが、デフォルトだと10件ずつ表示であったり、また、100件表示したら画面に収まらずまた、高さがウィンドウに合わせてくれません。そこでこれらに対応して
- リサイズ時にv-data-tableの高さを自動調整する
- 常に全件を表示するようにする
- ヘッダーは固定化してスクロールしても常に表示
を装備したいと思います。simple-tableでも良いのですが、あちらはソートが出来ない等やはり不便なので、tableにてsimpel-tableの表現を実現するのが目的です。
※headerのJSONデータにwidthを入れておくとtableの各列の幅を規定する事が可能です。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
<div id="gridarea" style="width: 100%;" style="display:block" v-resize="updateDataTableHeight"> <template> <v-data-table :height="dataTableHeight" v-model="selected" :headers="headers" fixed-header :items="recdata" sort-by="appdate" hide-default-footer :items-per-page="-1" > <template v-slot:item.actions="{ item }"> <v-icon small class="mr-2" @Click="deleteItem(item)"> mdi-delete </v-icon> <v-icon small class="mr-2" @Click="viewrec(item)"> mdi-circle-edit-outline </v-icon> </template> </v-data-table> </template> </div> |
- id=gridareaというdivでv-data-tableを囲っておきます。また、このdivにv-resize時イベントとして、updateDateTableHeightという関数を割り当てて起きます。
- なお、gridarea自体をリサイズ時に画面に合わせる場合はjQueryで以下のようなコードを記述しておきイベント発生時には常に実行するようにします。
- v-data-tableのheightは、:height="dataTableHeight"とし、変数の値で動的に変化するようにしておきます。
- fixed-headerをつけることでスクロールしてもヘッダ部分は常に固定表示されます。
- hide-default-footerにてrows per pageなどのフッター部分を非表示にします。
- :items-per-page="-1"を指定する事で、常に全件表示になります。
- あらかじめ、gridareaに対してheightを指定しておいてしまうとリサイズ時の値変更が何故かできなくなるので、高さ指定は外しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//divの高さを自動補正する関数 function setGridHeight(){ var layoutHeight = $(window).height() - 10; $('#gridarea').css('height', layoutHeight + 'px'); } //リサイズ時にウィンドウにフィットさせる window.onresize = function(){ setGridHeight(); } //gridのサイズを自動でウィンドウにフィットする $(document).ready(function () { setGridHeight(); }); |
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
//Vue.jsを初期化 vm = new Vue({ el: '#app', vuetify: new Vuetify(), name: "table", data: () => ({ recdata: recdata, dataTableHeight: 0, selected: [], headers: [ { text: '申請日', value: 'appdate', width: '150' }, { text: '社員番号', value: 'uid', width: '130' }, { text: '申請者名', value: 'uname', width: '150' }, { text: '内容', value: 'description' }, { text: 'アクション', value: 'actions', sortable: false, width: '150' }, ], }), methods: { updateDataTableHeight () { //高さを動的変更 var layoutHeight = $(window).height() - 98; this.dataTableHeight= layoutHeight; }, }, mounted : function(){ //管理者宛通知一覧をGAS側から取得する google.script.run.withSuccessHandler(onSuccess).getAdminNotif(); //サイズ調整 setGridHeight(); }, }); |
- データ取得して返り値をrecdataに入れるonSuccess関数については記載を省略しています。
- v-resizeでupdateDataTableHeightが実行されて、リサイズした際のフィットするサイズをdataTableHeightへ入力しています。
- mountedにてマウント完了後にGAS側へデータを要求するようにしています。
図:通知一覧などの全件を表示したい場合に利用する。
テーブルの値をその場で修正したい
テーブルに表示されてる内容のうち、1個だけをその場で修正したいみたいな事例は結構あります。レコード全部を修正可能にしたいのであれば、別途Dialogを用意して値を呼び出して保存する仕組み等を用意すれば良いですが、インスタントにそこだけちょっと編集したいといった場合には、ちょっとこれでは大掛かりです。
そこで用意されてるのがDataTableに用意されてるv-edit-dialog。これを既存のv-data-tableに加えてあげれば、その場でサクっと修正する機能をつけることが可能です。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
<v-data-table :height="300" v-model="selected" :headers="headers" fixed-header :items="recdata" sort-by="id" hide-default-footer :items-per-page="-1" > <template v-slot:item.timeval="props"> <v-edit-dialog :return-value.sync="props.item.timeval" large persistent @save="save" @cancel="cancel" @open="open" @close="close" cancel-text="キャンセル" save-text="保存" > <div>{{ props.item.timeval }}</div> <template v-slot:input> <div class="mt-4 title"> 時間の修正 </div> <v-text-field v-model="props.item.timeval" label="Edit" single-line autofocus :rules="[rules.required,rules.timeman2]" ></v-text-field> </template> </v-edit-dialog> </template> </v-data-table> |
- 今回はrecdataの中にあるtimevalという列の値を編集対象にしているので、v-slot:item.timevalとして設定しています
- saveやcancelにメソッド割当がありますがなくても問題ありません。自分の場合、セーブをした場合には、例えば登録データのtimevalの合計を計算してプロパティに再格納などを書いていたりします。
- 標準だと英語表記になってしまうので、cancel-textやsave-textで表示名を変更しています。
- :rulesでvalidationの指定も可能です。
- 本体側のDialogで提出をしない限り、この変更が大元のデータ群にマージされないようにしてあります。
- 保存を実行した時になにか追加のコードがなくても、きちんと保存はされます。
図:時間の文字をクリックすると直接編集のダイアログが出てくる。
レコード編集時の注意点
前項にてアクションにボタンを追加していますが、このうち編集を行う関数として、viewrec関数をサンプル中に記載しています。この時例えば別途用意したダイアログなどで編集を行う場合には、そのレコード用の一時的な変数に値を代入し、ダイアログの各フォームパーツにv-modelで参照するようにするわけなのですが、この時、viewrecには引数としてitemがつけられています。このitemは1行分のレコードデータのみが入っています。
問題なのは、このitemをそのまま一時的な変数に入れて編集をすると、「大元のレコードデータが直接書き換わる」点です。1行分しかデータが入っていないのですが、このitemは大元のレコードの参照になっているので、まだ保存する気が無いのに大元のデータが編集されてしまうので問題です(VBAで言うところの参照渡しみたいな状態)。この場合一時変数にはitemの値を直接入れずに以下のようなスタイルで入れるようにしましょう。
1 2 3 4 5 |
viewrec(item){ //itemをダイアログに追加 var json = JSON.stringify(item); this.temppj = JSON.parse(json); }, |
- itemはJSON.stringifyにてjsonという変数に格納する
- このjsonの値を一時的な変数であるにJSON.parseしてから入れてあげる
これだけです。これでVBAで言うところの値渡しの状態になるので、ダイアログ上で編集をしても大元のデータが書き換わったりしなくなります。直接書き換わるほうが便利なケースもありますけれどね。ちなみに、ダイアログ側は以下のような形にしてあります。
1 2 3 4 5 |
<v-row> <v-col cols="12" md="6"> <v-text-field label="PJタイトル*" v-model="temppj.pjtitle" class="input-items"></v-text-field> </v-col> </v-row> |
- v-modelにより一時的な変数temppjを参照させています。
- temppjは前述のコードで大元のデータの塊であるitemからのデータをdialogを開くときに入れています。
- 別途セーブするボタンを押した時に、はじめて大元のデータ塊に対してマージする仕組みが別途必要です。
データを入れ替えた時にスクロール位置をトップに戻す
v-data-tableなどの値をダイナミックに入れ替えた時、その時点でのスクロール位置からそのままではトップ位置に戻ってくれません。そのためそのままだとユーザはいちいちトップまでマウスで掴んでスクロールさせて上げる必要があります。
これでは非常に不便ですが、v-data-tableにはそのような問題を解決するメソッドも用意されていません。そこで使うのが、やはりjQuery。v-data-tableはclassとして、「.v-data-table__wrapper」が指定されています。また、v-data-tableにidを指定しても良いでしょう。この時、このテーブルに対して以下のコードを実行することで、v-data-tableの一番上の位置までスクロールバーを戻してくれます。
1 2 |
//v-data-tableのスクロールバー位置を上に戻す $('.v-data-table__wrapper').scrollTop(0) |
地味に結構遭遇するパターンなので、jQueryもまだまだ手放せないですね。
列を固定化する
v-data-tableには現在、列を固定化して横スクロール時にそのまま表示しつづける為のオプションが存在しません。2018年から要望は出ているものの現在まで搭載されず、フォークしたデモでは実装してる方もいるのですが、本プロジェクトにはマージされていません。
v-data-tableの実体はtableそのものなので、CSSで固定化するのが現実的な方法なのですが、1列の場合と複数列の場合で結構苦労しました。以下のようなコード体系で実現が可能です。
CSS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
/* datatableの列を固定化する(1列目) */ #gridarea > div > div > table > tbody > tr > td:nth-child(1), #gridarea > div > div > table > thead > tr > th:nth-child(1) { position: sticky !important; position: -webkit-sticky !important; left: 0px; z-index: 9980; background:white; } #gridarea > div > div > table > thead > tr > th:nth-child(1) { z-index: 9981; } /* datatableの列を固定化する(2列目) */ /* 1列目の幅の分だけ、leftの値を増やして固定化する */ #gridarea > div > div > table > tbody > tr > td:nth-child(2), #gridarea > div > div > table > thead > tr > th:nth-child(2) { position: sticky !important; position: -webkit-sticky !important; left: 200px; z-index: 9980; background:white; } #gridarea > div > div > table > thead > tr > th:nth-child(2) { z-index: 9981; } |
- 2列目の固定は別に用意が必要。違いは、leftの値。1列目の幅(headersで1列目のヘッダの幅を指定しておく)の分だけ、指定しておく必要がある
- ヘッダ部分だけでなく、セルの部分も固定化しないと、ヘッダだけが固定化してスクロールしてしまうので注意
HTMLコード
1 2 3 4 5 6 |
<div id="gridarea" style="width: 100%; height: 300px;"> <template> <v-data-table :height="dataTableHeight" fixed-header :headers="headers" :items="recdata"> </v-data-table> </template> </div> |
- fixed-headerはつけておいたほうが良いでしょう(これは縦スクロールでヘッダを固定化するオプション)
- 外側のIDがgridareaのDIVで今回は括っています。このIDを使ってCSSで指定しています。
図:2列固定化しました
多数のレコードを表示すると激重になる
Vuetifyのv-data-tableにて、非常に多数のデータを一度に表示してみるとわかるのですが、アプリ全体の動きが激遅になります。だいたい500件表示を超えた辺りから、だんだんモッサリしてきて、10000件表示だと表示されるまでも相当待たされた挙げ句、スクロールがもたついて正直使えるレベルではありません。
もともとページネーションで数十件ずつ表示などの運用が前提のコンポーネントなので、このような使い方をしたい場合には、素直にページネーションを利用するか?Cheetah Gridのやag-grid, slickGridような高速なグリッドコンポーネントに乗り換えたほうが、小細工をするより遥かに楽になれます。
他にもこちらのサイトにあるように、様々なGridコンポーネントが存在します。
特定の値に対してハイパーリンクを貼る
datatableの特定の値がURLやmailtoなどで、それに対応する文字列などがある場合(ない場合はURL文字列にリンクを貼る)、その文字をクリックすると、Electron内で新規ウィンドウを開いてURLを開くような動作を付け加える事が可能です。
以下の場合、reporturlがURL文字列の入ってるものになりこれに対してハイパーリンクを貼る仕組みにしてみました。
1 2 3 4 5 6 7 8 9 10 11 12 |
<div id="gridarea" style="width: 100%; height: 300px;"> <template> <v-data-table :height="dataTableHeight" v-model="selected" :headers="headers" fixed-header :items="compdata" sort-by="compid" hide-default-footer :items-per-page="-1"> <template #item.reporturl="{ item }"> <a target="_blank" :href="item.reporturl"> {{ item.reporturl }} </a> </template> </v-data-table> </template> </div> |
v-data-table内に、templateタグを入れて、item.reporturlを対象にaタグを加工して入れる仕組みです。尚、{{ item.reporturl }}に関しては別のフィールドの値を指定すれば、その文字列に対してリンクを貼れるので、そちらのほうがスマートです。
特定の値を元にフォント色を変更する
特定の値のみ色を変更する
意外と需要があると思われる「特定の値を元にフォントの色を変更する」という方法が結構面倒だったのでメモがてら残しておこうと思います。これには2パターンあり、その特定の列の値のフォント色だけ変更する場合と、その値の含まれてるレコード全体を変更する場合がある。今回はitemの中のtstimeの値を元に判定させています。
それぞれ少しずつ異なります。まず、特定の値だけフォントカラー変更する場合は
1 2 3 4 5 |
<v-data-table v-model="selected" :headers="headers" fixed-header :items="recdata"> <template v-slot:item.tstime="{ item }"> <span v-bind:class='isRed(item)'>{{item.tstime}}</span> </template> </v-data-table> |
として、v-bindのclassを動的に判定して変更する関数としてmethodに以下の関数を用意する。style-1とstyle-2はそれぞれCSSにてcolorをclass名として定義しておく。
1 2 3 |
isRed: function (item) { return item.tstime > 0 ? 'style-1' : 'style-2' }, |
図:遅刻早退の時間が0以上で赤くなった
特定の値を元に行全体のフォント色を変更する
行の場合は、:item-classを追加して、関数としてisRedを指定しておく
1 2 |
<v-data-table v-model="selected" :headers="headers" fixed-header :items="recdata" :item-class="isRed"> </v-data-table> |
methodにはisRed関数を用意する。style-1とstyle-2はそれぞれCSSにてcolorをclass名として定義しておく。codePenでこの事例が紹介されています。
1 2 3 |
isRed: function (item) { return item.tstime > 0 ? 'style-1' : 'style-2' } |
テキストの表示制限をしたい
DataTableのカラムに表示するテキストが長すぎて、テーブルの枠の幅を超えて表示されたり、縦に長くなったりと、非常に見づらくなるケースがあります。特に元データの備考欄などのような長文を書いてる場合、ダイアログで表示する分には良いですが、一覧表示のDatatableでやられると使い勝手が非常に落ちます。
ということで、例えば100文字を超えたら省略といったような文字数表示制限をしたい場合には、1つ関数をつくって対処します。vueのmethodに以下の関数をまず追加します。
1 2 3 4 5 6 7 8 9 10 |
//文字数削る supercut(text){ let cuttext = text.substr(0, 100); if(text.length >= 100){ cuttext = cuttext + "…"; } return cuttext; }, |
次にdatatableの該当のカラム指定にて、関数に食わせる形で表示をさせてやります。備考欄はdescriptionというキーで格納されています。
1 2 3 4 5 6 7 8 9 10 |
<div id="gridarea9""> <template> <v-data-table :height="dataTableHeight" v-model="selected" :headers="headers8" fixed-header :items="templist" sort-by="ID" hide-default-footer :items-per-page="-1"> <template v-slot:item.description="{ item }"> <span>{{ supercut(item.description) }}</span> </template> </v-data-table> </template> </div> |
{{ supercut(item.description) }}の部分がそれで、templateのv-slotにてitem.descriptionを指定し、表示箇所で同じく関数の引数に記述。これでデータのうち、descriptionに該当する値に関しては、100文字制限が掛けられて表示がされます。あくまで表示だけなので、元データに変更は加えていません。
図:100文字でカットされて(…)が表示された
行クリックとアクション
v-data-tableの行をクリックしてその内容に応じて処理を分けたい場合があります。この場合には以下のようにしてあげます。なお、classとしてrow-pointerを加えていますが、マウスカーソルをリンクの場合のカーソルに変更する為のCSSも入れてあります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<template> <v-data-table :height="dataTableHeight" :headers="headman2" fixed-header :items="nowdirlist" sort-by="update" :sort-desc="true" hide-default-footer :items-per-page="-1" @click:row="clickRow" class="row-pointer" > <template v-slot:item.actions="{ item }"> <v-icon small class="mr-2" @click.stop="deleteItem(item)" color="red"> mdi-delete </v-icon> </template> </v-data-table> </template> |
- @click:rowを加えることで、行クリック時イベントとなり、行のデータが関数のitem引数で取得できます。
- また、この場合、アクション列のボタンをクリックすると、行クリック=>アクションのボタンという順番で実行されてしまうので、アクションボタンの側は、@click.stopで関数を指定することでバッティングを防ぐことが可能です。
- row-pointerのCSSは以下のようなものを指定します。
123.row-pointer:hover {cursor: pointer;}
Validation
フォームの入力内容がきちんとボックスの内容に適合してるものなのかチェックする為の機能がvalidation。例えば数値しか受け付けたくない場所に文字や、メールアドレス欄なのにRFCの規則に従っていないメアドとか、半角全角、入力必須などがValidationの対象です。この機能を使わないとなると、サーバに送信時などで全部の入力欄について自分で一個ずつJavaScriptで検査してNGなら止めるみたいなコードを書かなければなりません
少数ならそれでも良いのですが、業務用アプリケーションのように入力欄の多い場合、非常にメンテナンス性が落ちます。これを簡単にしてくれるのがValidation機能です。Vuetify標準で搭載されてるものの他に、Vue.jsのプラグインとして、VeeValidateやVueValidateといったValidation専用のプラグインもリリースされています。
ソースコード
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<v-app> <v-card> <v-toolbar dark color="primary" class="fixed-bar"> <v-toolbar-items> <v-btn dark text @click="saverecord()"> 保存 </v-btn> <v-toolbar-items> </v-toolbar> <v-form ref="formman"> <v-card class="mx-auto" elevation="2"> <v-card-text> <v-container> <v-row> <v-col cols="12" sm="6" md="4"> <v-text-field label="社員番号*" required v-model="selerec.emp_id" :rules="[rules.required,rules.numchk]"></v-text-field> </v-col> <v-col cols="12" sm="6" md="4"> <v-text-field label="氏名*" hint="社内呼称の名称" required v-model="selerec.empname" :rules="[rules.required]"></v-text-field> </v-col> </v-row> </v-container> </v-card-text> </v-card> </v-form> </v-card> </v-app> |
- 保存ボタンにはsaverecord()を呼び出すように割り当てて起きます。(保存時に一括Validationチェックもやらせる為)
- 各入力欄のv-text-field等やv-select等には、:rules=[rules.required]などを割り当てます。これがValidationチェックの項目で、JS側で定義しておく必要があります。複数割り当てる場合は、配列内でカンマ区切りで、[rules.required, rules.numchk]といた具合に割り当てることが可能
- rulesが定義されていない入力欄はValidationの対象外になります。
- 全入力欄は必ずv-formでくくり、ref="formman"のように名前をつける事が必要です
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
vm4 = new Vue({ el: '#app4', vuetify: new Vuetify(), data: () => ({ ・・・・中略・・・・ rules:{ //必須項目のvalidation required: value => !!value || "必ず入力してください", //メアドのvalidation mail: value => { var pattern = /^(([^<>()[\]\\.,;:\s@]+(\.[^<>()[\]\\.,;:\s@]+)*)|(.+))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; if(value == "" || value == undefined){ return true }else{ return pattern.test(value) || 'メアドの形式がオカシイ' } }, //日付形式のvalidation dateman: value => { var pattern = /^(\d\d\d\d)\/(\d\d)\/(\d\d)$/; if(value == "" || value == undefined){ return true }else{ return pattern.test(value) || '日付の形式がオカシイ' } }, //日付形式のvalidation timeman: value => { var pattern = /^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/; if(value == "" || value == undefined){ return true }else{ return pattern.test(value) || '時刻の形式がオカシイ' } }, //数字のvalidation numchk: value => { var pattern = /^[0-9]*$/; if(value == "" || value == undefined){ return true }else{ return pattern.test(value) || '数字の形式がオカシイ' } }, //全角カナチェック kanachk:value => { var pattern = /^[ァ-ンヴー]*$/; if(value == "" || value == undefined){ return true }else{ return pattern.test(value) || 'フリガナの形式がオカシイ' } }, //ローマ字チェック romachk:value => { var pattern = /^[a-zA-Z]*$/; if(value == "" || value == undefined){ return true }else{ return pattern.test(value) || 'フリガナの形式がオカシイ' } }, //URLチェック url:value => { var pattern = /(http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/; if(value == "" || value == undefined){ return true }else{ return pattern.test(value) || 'URLの形式がオカシイ' } }, //インボイス番号のvalidation invchk : value => { //頭の文字がTであること let tstring = value.substr(0, 1); if(tstring == "T"){ //数値部分を取り出す let tnum = value.replace('T', ''); //13桁かどうかをチェック if(tnum.length == 13){ //数値部分が全部数値かどうかチェック var pattern = /^[0-9]*$/; if (tnum == "" || tnum == undefined) { return true } else { return pattern.test(tnum) || '数字の形式がオカシイ' } }else{ //数値部分が13桁じゃないよ return false || '数値部分が13桁じゃないよ'; } }else{ //頭の文字がオカシイパターン return false || '頭の文字がTじゃないよ'; } } }, }), methods: { saverecord(){ //書き込みするレコードのrecidを取得する var recid = this.selerec.recid; if (this.$refs.formman.validate()) { // すべてのバリデーションが通過したときのみ //GAS側へデータを送る側へデータを送る google.script.run.withSuccessHandler(onSuccess).oninsert(this.selerec,fields,recid,lastedit) } else { console.log("NG") } } } }) |
- vuetifyの初期化のdataの中にrulesは定義します
- rulesの中に複数タイプのValidationルールを定義し、HTML側はrules.numchkといった形で呼び出します。
- required: value => { ここに処理 }といった形で構文を定義し、valueに対してチェックを掛けます。最後にreturnで返すのですが、trueもしくはfalseのどちらかの値を返すようにします(falseでValidationはNGとなる)
- Validationはほとんどの場合で正規表現を使って検証します。
- 業務用だと必須項目、メアド、日付の形式、カタカナ、ローマ字、数値で概ねValidationのチェックは可能(数値の場合は文字数カウントなんてのもありますね)
- 保存ボタンに割り当てたsaverecord()はmethodsに定義する。Validationは入力中でも働くのですが、送信時に一括でチェックも可能。
- this.$refs.formman.validateにて一括チェックします。formmanがformに割り当てた名前になります。
- validateのチェック結果、全部が適合してると判断されるとtrueが帰ってくるので、これをもとにGAS側へデータを送って書き込ませるようにします。
図:入力欄チェックはデータの整合性を保つのに必要
Expansion Panel
いわゆるアコーディオン式に畳めるパネルメニューです。クリックする事で開かれば広がり、普段はたたまれているので、効率よくコンテンツを収納出来るUIパーツの一つ。v-ifやv-forと組み合わせて、配列からパネルデータを生成しながら使うシーンが多いと思います。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<header> <style> /* パネルのサイズをスマートにする */ .v-expansion-panel-header{ padding:8px 8px; min-height:30px; } .v-expansion-panel--active>.v-expansion-panel-header{ min-height:30px; } </style> </header> <body> <!-- multipleを外すとシングル表示になります --> <v-app id="app"> <v-expansion-panels accordion multiple v-model="panel"> <v-expansion-panel v-for="(item,i) in 5" :key="i" > <v-expansion-panel-header>パネル名</v-expansion-panel-header> <v-expansion-panel-content> コンテンツをここに記述 </v-expansion-panel-content> </v-expansion-panel> </v-expansion-panels> </v-app> </body> |
- multiple指定で複数パネルを同時に開く事が可能です。
- v-forで配列データからループでパネルを構成しています。
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 |
<script> var vm; //Body読み込み時に初期化する vm = new Vue({ el: '#app', vuetify: new Vuetify(), data: () => ({ panel: [0, 1], //パネルの0番、1番目をデフォルトで開いておく }) }); </script> |
- v-expansion-panelsでv-modelにてpanelを指定しておいて、panelにて配列にて数値を指定すると、指定番目のパネルをデフォルトで開いた状態にしてくれます。
- 今回はv-forで動的にv-expansion-panelを生成してるだけなので、0番目と1番目が開いた状態に。
図:こんな感じのパネルを生成可能です。
List
前述のアコーディオンメニューに似ていますが、これも非常に良く利用する所謂ドロップダウンメニューの発展版であるリストです。クリックすると、広がりサブメニューが出てくる仕様です。自分の場合は前述のDrawer等と組み合わせて利用しています。また、スプレッドシートとの組み合わせでメニューを構築するように仕込むと非常に便利です。
Expantion Panelはスマフォ対応では扱いにくいですが、こちらはスマフォ対応する上では非常に扱いやすいです。
図:メニューがこんな感じで広がる
図:データ自体は結構単純
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<v-list-group no-action v-for="(header,i) in headers" :key="i" v-model="item.active"> <template v-slot:activator> <v-list-item-content> <v-list-item-title> <span>{{ header.text }}</span> </v-list-item-title> </v-list-item-content> </template> <v-list-item v-for="element in header.elements" :key="element.id" v-bind:id="element.id" > <v-list-item-title>{{element.text}}</v-list-item-title> <v-list-item-icon> <v-icon color="red">{{element.icon}}</v-icon> </v-list-item-icon> </v-list-item> </v-list-group> |
- v-list-groupでメインメニューをループで構築しています。headersにはGAS側からのデータを入れてあります。
- メインメニューに属するサブメニューをv-list-itemでこちらもループで構築します。
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
//GAS側からのプロパティ値をpropdataに入れる function onSuccess(data){ //データを取得する vm.headers = JSON.parse(data); } var vm; vm = new Vue({ el: '#app', vuetify: new Vuetify(), data: { headers : null, }, mounted : function(){ //GAS側から一括でプロパティ値を取得する google.script.run.withSuccessHandler(onSuccess).getvlist(); }, }) |
- GAS側からデータを取得してそのままheadersに返り値のJSONを入れるだけ
GAS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
//vlistの元データを取得する function getvlist(){ //スプレッドシートを取得 var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("vlist"); //データを取得 var header = sheet.getRange("A2:B").getValues(); var listman = sheet.getRange("D2:G").getValues(); //返却用の配列を用意 let array = []; //headerを回してbukaのelementsを構成する for(var i = 0;i<header.length;i++){ //レコードを一個取り出す let rec = header[i]; //IDが空ならば処理終了 if(rec[0] == "" || rec[0] == undefined){ break; } //一時配列を用意してデータを追加 let temparr = { "text": rec[1], "elements":[] } //部門コードを取得する let recid = rec[0]; //listmanを回してelementsを構築する let templist = []; for(var j = 0;j<listman.length;j++){ //レコードを一個取り出す let list = listman[j]; //listコードを取得する let templistcode = list[1]; //listcodeと一致するものだけを取得する if(recid == templistcode){ //element要素を作成 let bukaarray = { id:list[0], text:list[2], parent:list[1], icon:list[3] } //tempbukaに追加する templist.push(bukaarray); } } //tempbukaをelementsに追加する temparr.elements = templist; //temparrをarrayに追加 array.push(temparr); } //作成結果を表示 console.log(JSON.stringify(array,null,"\t")) //値を返す return JSON.stringify(array); } |
- メインとサブのデータをそれぞれ取得し、HTML側のheadersに入れるデータを生成。扱いやすい用に予め加工しておきます。
- textがメインメニュー、elementsがそれにぶら下がるサブメニュー項目一式を入れています。
- メインメニューとそれにぶら下がるサブメニューをIDとPIDで連結して以下のようなJSONで構築して返します。
12345678910111213141516171819202122232425[{"text": "くだもの","elements": [{"id": 1,"text": "チェリー","parent": 1,"icon": "mdi-fruit-cherries"},{"id": 2,"text": "レモン","parent": 1,"icon": "mdi-fruit-citrus"},{"id": 3,"text": "ぶどう","parent": 1,"icon": "mdi-fruit-grapes"}]}]
Chips
ウェブアプリケーション等でよく見かける「タグ」であったり、カテゴリ分けを付けたり、場合によっては添付ファイルの表示として使われるのがこの「チップ」。地味な機能ではあるものの、結構奥深くて、実際にこれを使って添付ファイルの有無や操作から削除などを実装してみましたが、結構色々とノウハウがあります。
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<v-card class="mx-auto" outlined> <v-chip color="#17204d" text-color="yellow" v-for="(attach, index) in attachment" :key="index" close @click="chipclick(attach)" @click:close="remove(attach)" > {{ attach.name }} </v-chip> </v-card> |
- あらかじめ、vueのattachment変数にデータの塊をいれておきます(JSON形式)
- v-forでデータを回して、チップを複数生成していきます。この時、nameをチップの表示文字列として利用します。
- closeプロパティを付けると閉じる事が出来るようになります。またこのcloseにアクションを付ける場合は、@click:closeで関数を指定します。
- チップ自体のクリック時に関しては通常通り、@clickで関数を指定すれば、attachの中身を渡してアクションを実行可能です。
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
//チップクリック時は直接ダウンロード chipclick (item) { //fileidを取得 var fileid = item.fileid; //URLを構築 var aturl = "https://drive.google.com/uc?export=download&id=" + fileid; //閲覧ダイアログの時のみダウンロードを実行 if(this.viewDialog == true){ //新規ウィンドウで開かせてダウンロード window.open(aturl, "_blank"); } }, remove(item){ //uuidを取得する var uuid = item.uuid; //attachmentから除外する for(var i = 0;i<this.attachment.length;i++){ //レコードを一個取り出す let rec = this.attachment[i]; //uuidが一致する値を除外する if(uuid == rec.uuid){ this.attachment.splice(i, 1); break; } } }, |
- 通常のチップクリック時処理と、クローズクリック時処理の2つを記述します(Closeが不要の場合はremoveはいらない)
- remove時はattachに入ってる値を元に、attachment変数から対象のチップデータを除外します。
図:jpgファイルをchipで表現してみた
組み合わせの妙技
Expantion Panel内のリストをv-forで作る
アコーディオンメニューであるExpantion Panelですが、そのヘッダ部分はv-forで普通に作ることが可能です。しかし、そのヘッダに属するリストをv-expansion-panel-contentの中に作り込もうとした場合、v-expansion-panelの名前に紐付くものをデータからフィルタして・・・とやると非常に厄介です。
そこではじめからデータ構造を工夫しておけば、computedなどを駆使してなんてせずとも、簡単にそのヘッダ名に属するサブ項目をリストの形で構築する事が可能です。こちらのサイトの内容を参考にしました。
HTML部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<v-expansion-panels accordion multiple v-model="panel"> <v-expansion-panel v-for="(header,i) in headers" :key="i" > <v-expansion-panel-header> <span>{{ header.text }}</span> <template v-slot:actions> <v-icon color="blue">mdi-arrow-down</v-icon> </template> </v-expansion-panel-header> <v-expansion-panel-content v-for="element in header.elements" :key="element.id" > {{element.text}} </v-expansion-panel-content> </v-expansion-panel> </v-expansion-panels> |
- Expantionのアイコンと色も変えておきました。
- headersにデータが部分が入っており、ここのtext項目を元にアコーディオンメニューをv-forで構成します。
- v-expansion-panel-contentでのv-forはheadersの中のelements項目の中身を回して、その中のtext項目を生成する仕組みです。
データ部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
headers: [ { text: '営業部', elements: [ {id:1, text:"ホールセールス"}, {id:2, text:"リテールセールス"}, ] }, { text: '管理部', elements: [ {id:1, text:"人事課"}, {id:2, text:"総務課"}, {id:3, text:"管理課"}, ] } ] |
- データ構造は上記のようなスタイル
- 1レコードの連想配列の中に入れ子でelementsという配列を入れておき、これがtextの部署に属する課といった具合に2つ目のv-forでループを回す対象になります。
- データ構造の工夫だけで複雑なロジックを構築する事なく目的のものを作ることが可能です。
図:こんな感じのをデータから生成する
テキストボックスとカレンダー連動+α
非常に便利でリッチなUIを提供してくれるVuetifyですが、公式ドキュメントだけでは実務では困るケースが結構あります。特に今回社内向けアプリを作ってて困ったのが、テキストボックスとカレンダーの連携。困った点は以下の通り
- テキストボックスクリックでカレンダー表示だと手修正時に面倒(ボタンを別途用意する必要性)
- カレンダーを要するものが1個ならばVue.js内に用意した変数と連動で良いが、たくさんある場合にはこの手は非常に面倒(カレンダーの数だけ用意が必要)
- Vuetifyのカレンダーは、yyyy-mm-ddの形式でなければならないが、テキストボックス内に表示されるのはyyyy/mm/dd(このまま渡すとエラーになる)
- 値を読み書きする対象は、単一の値ではなくJSONの塊であるため、ロジックが必要(テキストボックスへの連結はv-modelで連結で良いのだが・・・)
これらを解消するにはちょっと複雑な仕組みが必要です。
ソースコード
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<style> /* DatePickerの土日の色 */ .v-date-picker-table.v-date-picker-table--date > table > tbody tr td:nth-child(7) .v-btn__content { color:blue } .v-date-picker-table.v-date-picker-table--date > table > tbody tr td:nth-child(1) .v-btn__content { color:red } </style> <v-form ref="form"> ・・・前略・・・ <v-menu :close-on-content-click="false" :nudge-right="40" transition="scale-transition" offset-y min-width="290px"> <template v-slot:activator="{ on, attrs }"> <v-text-field v-model="selerec.joindate" label="入社日付" type="text" hint="入社した日付を選択" persistent-hint required> <template v-slot:append-outer> <v-btn icon color="primary" dark elevation="0" @click="onBindName('joindate')" v-on="on"> <v-icon>mdi-calendar</v-icon> </v-btn> </template> </v-text-field> </template> <v-date-picker v-model="picker" @click="menu = false" locale="ja-jp" :day-format="date => new Date(date).getDate()" ></v-date-picker> </v-menu> ・・・後略・・・ </v-form> |
- 入社日付テキストボックスとはv-modelにてJSONデータであるselerecのjsondateに連結
- テキストボックス、ボタン、カレンダーであるv-date-pickerはv-menuで囲っておく(これ全体が一つの塊になる)
- ボタンクリック時には、カレンダーを呼び出すonBindName関数をイベントとして割り当てておく(v-on='on'も必要です)
- v-date-pickerはv-modelにてpickerという後述のComputedのget, setの変数を割り当てておく(これが日付データの受け渡しを担当)
- v-date-pickerはlocale="ja-jp"で日本語化。しかし、日がくっついてくるので、:day-format="date => new Date(date).getDate()"にて、綺麗にフォーマットする
- CSSにてカレンダーの土日の文字色は変更可能です。
- onBindName関数の引数には対象となるJSONのkey名を入れておき、読み書き対象の場所を特定できるように指定しておく。
- v-menuのclose-on-content-clickをtrueにするとピッカーで日付をクリックすると閉じるようになる
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
//グローバル変数 var binder = ""; //ダイアログを初期化する vm3 = new Vue({ el: '#app3', vuetify: new Vuetify(), data() { return { selerec: {}, date: null, } }, methods: { onBindName(args){ //対象の日付テキストのバインド先を格納する binder = args; //バインド先データの日付を取得し、dateに格納しておく try{ var dateman = this.selerec[binder]; this.date = dateman.replace(/\//g, '-'); }catch(e){ } }, }, computed: { picker: { get() { //変数dateから取得変換済みの日付をカレンダーに対して返す return this.date; }, set(val) { //カレンダーの選択日付をセットする this.date = val; var dateman = val.replace(/-/g, '/') this.selerec[binder] = dateman; } } } }) |
- グローバル変数binderにはonBindNameが取得した引数を格納。読み書き先のJSONのkey名を格納しておく(これにより、たくさんのカレンダー用の変数を用意する必要がなくなる)
- onBindNameにエラートラップをしてる理由は、値が空である場合にエラーとなるので、これを回避する為。
- onBindName関数にて、特定のJSONの日付データを取得。ただし、replaceを使って正規表現にて「スラッシュ」を「ハイフン」に変更して、dateに格納しておく
- v-onにてカレンダーが開かれると、v-date-pickerが表示される。すると、v-modelで連結してるcomputedのPickerにあるgetが発火。dateに格納されてる日付データを取得し、カレンダーに反映する
- カレンダー側で日付を選択すると、computedのpickerにあるsetが発火。日付データはvalに入ってるので、これを今度は「ハイフン」を「スラッシュ」に変更して、dateおよびJSONの対象のkeyの値に直接格納する
- カレンダー外をクリックすると、カレンダーは自動的に閉じる。カレンダーの表示非表示用の変数もこれで省略しているので、たくさんの変数を用意する必要がない
ちなみに、selerecには、1レコード分の様々なデータがkeyと値のペアでたくさん格納されている。
図:この手は非常によく使うテクニック
テキストボックスとタイムピッカー連動+α
前項でカレンダーピッカーとテキストボックスを連動させましたが、同時に割と人事労務系だと使う機会のあるのが「タイムピッカー」。こちらも前項同様テキストボックス横のボタンをクリックするとタイムピッカーが表示されて選ぶと時刻をテキストボックスに入れることができるようにします。ただ使いやすいかどうかというと・・・個人的にはシンプルなVue.jsプラグインである「vue2-timepicker」のほうが簡単な気がします。
ソースコード
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<v-form ref="form"> ・・・前略・・・ <v-menu :close-on-content-click="false" ref="menu" :nudge-right="40" transition="scale-transition" :return-value.sync="time" offset-y min-width="290px"> <template v-slot:activator="{ on, attrs }"> <v-text-field v-model="selerec.workman" label="作業時間" type="text" hint="作業した時間数" persistent-hint class="input-items"> <template v-slot:append-outer> <v-btn icon color="primary" dark elevation="0" @click="onBindName2('workman')" v-on="on"> <v-icon>mdi-timer-outline</v-icon> </v-btn> </template> </v-text-field> </template> <v-time-picker v-model="picker2" ref="picker" full-width format="24hr" @click="menu2 = false"></v-time-picker> </v-menu> ・・・後略・・・ </v-form> |
- 入社日付テキストボックスとはv-modelにてJSONデータであるselerecのworkmanに連結
- テキストボックス、ボタン、カレンダーであるv-time-pickerはv-menuで囲っておく(これ全体が一つの塊になる)
- ボタンクリック時には、カレンダーを呼び出すonBindName2関数をイベントとして割り当てておく(v-on='on'も必要です)
- v-time-pickerはv-modelにてpicker2という後述のComputedのget, setの変数を割り当てておく(これが時刻データの受け渡しを担当)
- onBindName2関数の引数には対象となるJSONのkey名を入れておき、読み書き対象の場所を特定できるように指定しておく。
- v-time-pickerにはrefとしてpickerを指定しておきます。後のコードで利用します
- v-time-pickerの表記は今回は24時間表記を使うので、format="24hr"を指定しています。
JS側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
//グローバル変数 var binder = ""; //ダイアログを初期化する vm3 = new Vue({ el: '#app3', vuetify: new Vuetify(), data() { return { selerec: {}, time: null, menu2: false, } }, methods: { onBindName2(args){ //対象の時刻テキストのバインド先を格納する binder = args; //バインド先の日付を取得 try{ var timeman = this.selerec[binder]; this.time = timeman; }catch(e){ } }, }, computed: { picker2: { get() { //バインド先から取得変換済みの日付をカレンダーに対して返す return this.time; }, set(val) {val //カレンダーの選択日付をセットする this.time = val; this.selerec[binder] = this.time; //時刻選択の状態に戻す this.$refs.picker.selectingHour = true; } } } }) |
- グローバル変数binderにはonBindName2が取得した引数を格納。読み書き先のJSONのkey名を格納しておく(これにより、たくさんの時刻用の変数を用意する必要がなくなる)
- onBindName2にエラートラップをしてる理由は、値が空である場合にエラーとなるので、これを回避する為。
- v-onにてタイムピッカーが開かれると、v-time-pickerが表示される。すると、v-modelで連結してるcomputedのPicker2にあるgetが発火。timeに格納されてる日付データを取得し、ピッカーに反映する
- タイムピッカー側で時刻を選択すると、computedのpicker2にあるsetが発火。指定のテキストボックスにhh:mmの形式で代入してくれる。
- タイムピッカー外をクリックすると、ピッカーは自動的に閉じる。
- タイムピッカーのバグ回避のために、再度同じテキストのタイムピッカーを開いた時に初期選択が「分」になってるのをリセットするために、this.$refs.picker.selectingHourをtrueにする。このためにv-time-pickerにrefとしてpickerを指定してある
図:こんな感じの時間選択が出てくる
タブとツールバーの連携技
自分のアプリでは、レコードをクリックすると、その詳細な内容を編集するダイアログを表示させ、その中ではジャンル毎に「タブ」でわけています。このタブをツールバーの中に配置しており、保存ボタンを別途用意しており、編集後に保存ボタンを押すことで、MySQL側へとデータを投げる仕組みになっています。
タブをクリックする事でジャンル毎の中身を切り替えて表示するのですが、各コンテンツ内容がダイアログの大きさよりも大きい場合、スクロールバーが表示されますが、ツールバーまでスクロールしてしまい、具合が悪いです。コンテンツ部分だけをスクロール出来るように細工が必要です。
ソースコード
ツールバーを固定化する為のCSS
1 2 3 4 5 6 7 |
/* DialogのToolbarを上部に固定化 */ .fixed-bar { position: sticky; position: -webkit-sticky; /* for Safari */ top: 0em; z-index: 9; } |
- ツールバー部分はposition:stickyにて上部に固定化する
HTML側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
<v-app> <v-dialog v-model="dialog" fullscreen hide-overlay transition="dialog-bottom-transition"> <v-card> <v-toolbar dark color="primary" class="fixed-bar"> <v-btn icon dark @click="dialog = false"> <v-icon>mdi-close</v-icon> </v-btn> <v-toolbar-title v-text="selerec.emp_name+'の情報詳細'"></v-toolbar-title> <v-spacer></v-spacer> <v-toolbar-items> <v-btn dark text @click="dialog = false"> 保存 </v-btn> </v-toolbar-items> <template v-slot:extension> <v-tabs v-model="model"> <v-tabs-slider color="yellow"></v-tabs-slider> <v-tab href="#tab-1"> 共通項目 </v-tab> <v-tab href="#tab-2"> 人事 </v-tab> <v-tab href="#tab-3"> 通勤費 </v-tab> <v-tab href="#tab-4"> 厚生 </v-tab> <v-tab href="#tab-5"> 勤怠 </v-tab> </v-tabs> </template> </v-toolbar> <!-- 各タブ内のコンテンツ --> <v-form ref="form"> <v-tabs-items v-model="model"> <v-tab-item value="tab-1"> ここにコンテンツを記述 </v-tab-item> <v-tab-item value="tab-2"> 人事U </v-tab-item> |