Vue.js + GAS　〜スプレッドシートを編集するサイドバーを作成する〜

# 概要

サイドバーはドキュメントやスプレッドシートに作成することができます
スクリプトファイルはコンテナバインドである必要があります。（スタンドアローンでは作成できません）
メニューのHTMLはどのように作っても構いませんが、私はvueを使ったSFCで作るのが好きです。

# サイドメニューを作成

## createMenu()とaddMenuでメニューを作成する方法

- onOpenはスプレッドシートを開いた時に実行される関数
- uiクラスのcreateMenuメソッドでメニューを作成する
- メニューを追加するにはaddItemメソッドを使う
- 最後にaddToUiメソッドでメニューが完成

``````javascript
function onOpen() {
 var ui=SpreadsheetApp.getUi()
 var userMenu=ui.createMenu("入力フォームを表示")
 userMenu.addItem('登録', 'openOfferMenu');
 userMenu.addItem("更新", 'openNegoMenu');
 userMenu.addItem('申請', 'openSettleMenu');
 userMenu.addToUi();
};

function openOfferMenu(){openMenu("new")}
function openNegoMenu(){openMenu("edit")}
function openSettleMenu(){openMenu("submit")}
// スプレッドシートが開かれたタイミングで実行
function onOpen() {
    // アクティブなスプレッドシートを取得
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    // メニューを格納する変数を初期化
    var menuEntries = [];
    
    // 項目名: 項目1 実行関数: function1 の項目を menuEntries に追加
    menuEntries.push({name: "項目1", functionName: "function1"});
    // 仕切りを追加
    menuEntries.push(null);
    // 項目名: 項目2 実行関数: function2 の項目を menuEntries に追加
    menuEntries.push({name: "項目2", functionName: "function2"});
 
    // そのスプレッドシートにメニューという名前で menuEntries を追加
    ss.addMenu("メニュー", menuEntries);
}
``````

## JSON形式でメニューオプションを設定する方法

``````javascript
function onOpen() {
 var subMenus=[
   {name:"ユーザーメニューを開く",functionName:"openUserMenu"},
   null,
   {name:"管理者メニューを開く",functionName:"openAdminMenu"}
 ];
 SpreadsheetApp.getActiveSpreadsheet().addMenu("編集メニュー", subMenus)
}

function openUserMenu() {  //menuには"offer","nego","settle"のいずれか
  var ui = SpreadsheetApp.getUi();
  var html = HtmlService.createTemplateFromFile("00application")
  ui.showSidebar(html.evaluate().setTitle("メニューを開く"));
}
``````

# サイドバーのHTMLを返す関数を作成

サイドバーの中身はHTMLで記述しています。
uiクラスのshowSidebarメソッドを使って、作成したHTMLテンプレートをサイドバーとして表示します。
include()は後に解説しますひとつのhtmlファイルに他のhtmlファイルを埋め込む時に使う関数です。

main.gs

``````javascript
function openMenu(menu) {
  var ui = SpreadsheetApp.getUi();
  var html = HtmlService.createTemplateFromFile("00application")
  html.menu=menu  //これの解説は後ほど
  ui.showSidebar(html.evaluate().setTitle(menu));
}

function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
``````
### 解説

- 二行目は、indexという名前のHtmlOutputオブジェクトを生成している。
- getUiメソッドはバインドしているドキュメント（コンテナといいます）のUIを操作するためのUiオブジェクトを取得する
- showSidebarメソッドは、htmlOutputオブジェクトをサイドバーに反映させて表示するメソッド

# 枠組みを作成（html側の全体像）

vue.jsを活用します。
メニューによって異なるページを表示しているように見えますが、用意しているページはapplication.htmlただ一つで、中に配置するコンポーネントを変えているだけです。

こういう作りにしている理由は、ページを開いた時のアクション（他のスプレッドシートからデータを読み込むなど）やページのレイアウトは共通しているため、別個にページを作ってしまうと一つ修正する時に全て修正する必要があるからです。

application.html

``````html
<script src="https://jp.vuejs.org/js/vue.js"></script> <!--vueのインストール-->

<?!= include("コンポーネント記述ファイル1") ?>  <!--これを記述することで、他ファイルに書いたhtmlを読み込んでいる -->
<?!= include("コンポーネント記述ファイル2") ?>
・・・

<div id="user_menu">
  <component1></component1>
  <component2></component2>
</div>

<script>
var vm=new Vue({
  data: {
  },
  ・・・
}.$mount("#user_menu")
``````

各コンポーネントは以下のように作成します。GASでは.vueファイルを使えないので、代わりにx-templateを使い、componentっぽくしています。

component.html

``````javascript
<script type="text/x-template" id="form">
  <form name="form" class="box">  //formにしているのはvueを使っていなかった時の名残なので、divでいいです
  </form>
</script>

<script>
Vue.component("component1",{
  template: '#form',
  props:{
  },
  methods:{
  }
})
</script>
``````

# 今のページが何なのか区別する

application.htmはひとつしかありませんので、今のページがnewなのかeditなのかsubmitなのかの状態をvueのデータに保持して、コンポーネントの表示有無を判断します。

それには、application.htmlのどこかに をつけてやり、gsでhtmlテンプレートを作成した時にmenuに値を代入し、ページ作成後にvueでmenuの値を読み込むという方法をとります。

application.html

``````html
<div id="user_menu">
  <span ref="menu"> <?= menu ?></span>
</div>

<script>
var vm=new Vue({
  data: {
    menu:"",
  },
  mounted() {
    this.menu=this.$refs.menu.innerHTML //今の画面がofferかnegoかsettleが判別
  },
・・・
``````
gs

``````javascript
function openMenu() {
  var ui = SpreadsheetApp.getUi();
  var html = HtmlService.createTemplateFromFile("00application")
  html.menu=""  //ここで"new","edit","submit"を入れる
  ui.showSidebar(html.evaluate().setTitle(menu));
}
``````

# サイトを開いた時に他からデータを引っ張ってくる

ページを開いた時に他サイトの情報（API、スクレイピングなど）を取ってくる必要があると思います。
ここでは複数のスプレッドシートの情報を引っ張ってきて、vueのdataに放り込みます

流れとしては、

1. html側からgoogle.script.runを使用してgs側のファイル取得関数を実行
2. gs側から返ってきた値をdataに挿入
3. 以上の動作をPromiseで行い、全てのデータ取得が終われば次のアクションに移る

gs側

``````javascript
②function doFunction(funcName){ //html側から送られてきた関数名を実行する
  return eval(funcName)  
}

③function get_funcName(){
//スプレッドシートをJSON形式で返す。シートは1枚目を取得し、1行目をヘッドに設定します。
  const contents=SpreadsheetApp.openById(fileId).getSheets()[0].getDataRange().getValues()
  const head=contents.shift()
  const values = contents.reduce((acc,cur,idx)=>{
    var value = cur.reduce((a,c,i)=>{a[head[i]]=c;return a;},{})
    acc.push(value);return acc;
  },[])
  return JSON.stringify(values)
}
``````

html側（application.html）

``````javascript
  created() {
    this.fetchDatas();
  },
  methods:{
 ④   fetchDatas: async function(){
      var self=this;
      const b=["file1","file2","file3"].map(e=>{return this.fetchData(e)});
      await Promise.all( b )
    },
 ① fetchData(dataName){ //ひとつのスプレッドシートから返される配列は一つだけの前提
      return new Promise((resolve,reject)=>{
      google.script.run
      .withSuccessHandler(res=>{
        var values=JSON.parse(res);
        this[dataName]=values;
        resolve(dataName+"はOK")})
②    .doFunction( "get_"+dataName+"()");
      })
    },
  }
``````
### 解説

①google.script.runでgs側のfileを返す関数を実行するのはfetchData関数です。
google.script.runはそのままでは非同期を実行しにくいので、Promiseを返すようにしています。

②google.script.runの末尾にはgs側で実行する関数名を記述する必要がありますが、今回はgs側の関数が複数あり、それら全てのために別々のgoogle.script.runを記述していては、いかにも冗長です。
そこで、スクリプトを文字列で書いて実行するevalを用いて、関数名を渡せばgs側でその関数が実行できるようにしています
（evalの使用はセキュリティ上の危険を伴いますが、googleの強固さを信じます）

③そして、指定のスプレッドシートのデータを取得し（ここではスプレッドシートの1行目をヘッドとしてオブジェクト形式に変換しています）返します。
gs側からhtml側にデータを渡す時はJSONにしましょう。（嵌った経験あり）

④上記一連の作業は並行処理で行います。
全てが終わってから、次のアクションに移るにはPromise.allを用います。
promise.allの中身は配列ですので、mapを使えばその配列全てが作成された後にthenに移るといった行動が可能になります
（ここではthen以降を省略しています）

# サイトを開いた時にログインっぽくする

先ほどのデータ取得は非同期で並行して進めているとはいえ、取得に時間がかかるデータがある時は少し時間がかかります。
そこで、ユーザー一覧の情報だけは別個で取得し、ユーザーがコードを入力している間に他のデータを取得するようにしました。
ここでは、サイドバーを開いたらモーダルウィンドウが開き、そこにユーザーがコードを入力したら、誰々がログインしているという状態を保持する機能を実装します。

``````javascript
var vm=new Vue({
  data: {
    staffs:[],
    staff:"",
  },
  created() {
    var self=this;
    this.fetchData("staffs").then(function(){
        var staff_code = prompt("コードを入力してください", "");
        self.staff=self.staffs.find(staff=>staff["コード"]==staff_code)
    })
  （"fetchDataの内容は先ほどと同じのため省略"）
`````

# 列の表示、非表示を切り替える

先ほどのログインで、vueのstaffデータには誰々がログインしているという状態が保持されます。
（ちなみにstaffは{code:1234,name:"アンリー",division:"第一営業部"}といった情報が入っています）

スプレッドシートで、この人が入力した行だけを表示するような機能を実装します。

コード	名前	部署
1111	スピッツ	第一営業部
2222	YOASOBI	第二営業部
1234	アンリー	第一営業部

html側（コンポーネントで作成しています）

``````html
<script type="text/x-template" id="simple-filter">
  <form name="simpleFilter" class="box">
    <h2>簡単絞り込み</h2>
    <button type="button" @click="hideExcept(`コード`,staff.group)">担当</button>
    <button type="button" @click="hideExcept(`部署`,staff.code)">自部署</button>
    <button type="button" @click="showAllRows()">全データ表示</button>
  </form>
</script>

<script>
Vue.component('simple-filter', {
  template: '#simple-filter',
  props:{
    staff:"",
  },
  methods:{
    hideExcept(column,key){
      google.script.run.hideRows(column,key)
    },
    showAllRows(){
      google.script.run.showAllRows()
    }
  }
});
</script>
``````

gs側

``````javascript
//列の非表示。条件を満たす値以外を非表示にする
function hideRows(column,key){
  var row_values=loc(column)
  var row_indexes=row_values.reduce((acc, cur, idx) => cur!==key  ? [...acc,idx+3]: acc, [])
  row_indexes.forEach(e=>sheet.hideRows(e))
}
function showAllRows(){
  sheet.showRows(1,lastRow+3)
}

//指定したキーの列データを配列で返す
function loc(column){
  const index=head.findIndex(key=>key==column) //headはスプレッドシートの1行目（グローバル変数として定義）
  const rows=values.map(row=>row[index-1])
  return rows
}
``````

### 解説

行の表示、非表示を切り替えるメソッドは、sheetクラスのhideRow（hideRows）、showRow（showRows）です。
hideRows()の引数には隠す行を番号で指定します。