Help us understand the problem. What is going on with this article?

CDN版Vue.jsで作るGoogleSpreadsheetお手軽サイドバー

More than 1 year has passed since last update.

はじめに

今回は今風のWebフロントエンドの見た目や機能を持つGASのサイドバー(GUIツール)を手軽に作成する方法を紹介します。

  • スプレッドシート等でちょっと気の利いたインターフェイスを作りたい
  • Vue.jsってのが流行ってるらしいのでちょっと使ってみたい

くらいのノリを想定しているため、ガチ勢の方には物足りない内容かと思います。
マサカリはジャンジャンお待ちしていますので、そちらでお楽しみいただければ幸いです。

最終的には、こんな感じの物を作ってみます。
Sidebar概要.gif

Vue.jsを使う理由と、CDN版Vue.js

Vue.jsに関しては昨今話題となっておりますし、Qiitaにもたくさんの記事がアップされていますので詳細は不要かと思います。
お手軽なDOM操作、2Way-binding、Transition他、多数な機能を取りそろえた非常に便利なWebフロント向けフレームワークです。
しかし、Vue.jsを用いた一般的なWebアプリ開発では、開発環境の構築が必須となります。
昨今では随分と構築難易度は下がりましたが、非エンジニアから見ればまだまだ複雑かと思います。

そこで出てくるのがCDN版のVue.jsです。
CDN版の場合、Vue.jsの全ての機能(例えば単一ファイルコンポーネント等)を使えるわけでは無いですが、script読み込みだけでVue.jsの便利な機能を手軽に使用する事が出来ます。
これは「とりあえず入れとけ!」的なJQueryのノリに近い感覚です。
加えて、Vue.jsベースのUIコンポーネントライブラリも豊富で、かつ、多くのライブラリはCDNからの読み込みだけで簡単に使えます。

Claspを使わない理由

2018年のGAS界隈はclaspの進化によって飛躍的に開発環境が向上しました。
使いにくいスクリプトエディタを離れ、VSCodeとGithubでモダンな開発!
Typescriptで型安全!と夢のような年でしたが、今回の記事では扱っていません。
まぁちょっとした機能やカスタマイズ程度は、サクっとスクリプトエディタだけで済ます方が取り回しが良いと思いますよ~。
本音としてはAdventCalendarにかなり多くのclasp記事が投稿されるようなので、そちらにお任せしようかなぁ…と

CDN版Vue.jsを使った基本的なサイドバーの雛形

さて、まずはミニマム構成のサイドバーと、ついでにダイアログ表示の雛形をご覧ください。
構成されるファイルは2つで、GASのコードの書かれたcode.gsと、サイドバーとして表示されるhtmlのテンプレートとなるindex.htmlです。

雛形のサーバー側コード

code.gs
/**
 * HTML側から呼ばれる関数
 * アクティブシートの最終行に文字列を追加する
 */
function myFunction() {
  SpreadsheetApp.getActiveSheet().appendRow(["Hello", "GAS", "World!"]);
}

/**
 * Spreadsheetを開いたときに自動的に呼び出される
 * 拡張メニューを追加し、独自サイドバーとダイアログを呼び出せるよう登録する
 */
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu("拡張メニュー")
    .addItem("サイドバーで開く", "showSidebar")
    .addItem("ダイアログで開く", "showModalDialog")
    .addToUi();
}

/**
 * GASプロジェクト内にあるファイルをテンプレートとして読み込み、出力可能なhtmlを作成する
 *
 * @param {String} filename
 * @return {Object} HtmlOutput
 *
 * HtmlOutput:
 *   https://developers.google.com/apps-script/reference/html/html-output
 */
function createHtmlOutput(filename) {
  var htmlTemplate = HtmlService.createTemplateFromFile(filename);
  // htmlTemplateに関して
  //   https://developers.google.com/apps-script/reference/html/html-template

  // ここでHTML側に渡す値を登録している
  htmlTemplate.messageFromGAS = "Hello GAS World!";

  // テンプレートを元にサーバーサイド処理を行い、最終的なHtmlOutputを生成する
  // evaluate後の値は上記の"Hello GAS World!"の文字列も埋め込まれている
  return htmlTemplate.evaluate();
}

/**
 * サイドバーを開く
 */
function showSidebar() {
  // プロジェクトに保存されているファイル名の末尾 .html は不要です
  // 下記の場合 index.html が読み込みこまれ、諸々サーバーサイド処理された結果が返される
  var ui = createHtmlOutput("index")
    .setTitle("サイドバー")
    .setSandboxMode(HtmlService.SandboxMode.IFRAME);

  SpreadsheetApp.getUi().showSidebar(ui);
}

/**
 * ダイアログを開く
 */
function showModalDialog() {
  var ui = createHtmlOutput("index")
    .setWidth(320)
    .setHeight(240)
    .setSandboxMode(HtmlService.SandboxMode.IFRAME);

  SpreadsheetApp.getUi().showModalDialog(ui, "ダイアログ");
}

雛形のフロント側コード

index.html
<!DOCTYPE html>
<html>
<head>
  <!-- 各種スタイルシートの読み込みは、ここで行ってください -->
  <!-- なお、外部から読み込むスタイルシートは https でホストされている必要があります -->

  <style>
    html { overflow-y: auto; }
    /* 垂直スクロールバー制御用 */
  </style>
</head>

<body>
  <div id="app">
    <!--↓↓ vue.jsの記述は全てこの div#app の内部に書いてください ↓↓-->

    <p>{{message}}</p>
    <button @click="runServerFunction">GASの関数を実行</button>

    <!--↑↑ vue.jsの記述は全てこの div#app の内部に書いてください ↑↑-->
  </div>

  <script src="https://jp.vuejs.org/js/vue.js"></script>
  <!-- vue.jsや関連ライブラリのCDNからの読み込みは、ここで行ってください -->
  <!-- 外部から読み込むライブラリは https でホストされている必要があります -->

  <!--  <script src="https://jp.vuejs.org/js/vue.min.js"></script> -->
  <!-- 本番リリース時はこちらを使ってください -->

  <script>
    new Vue({
      el: "#app",
      data: {
        message: <?= messageFromGAS ?>,
        // ここの<?= xxx ?>は force-printing_scriptlets というHtmlTemplateの独自記法です
        // HtmlTemplateから実際の表示用のHTMLに変換される際、サーバー側で文字列に置き換えられます
        // 参考 https://developers.google.com/apps-script/guides/html/templates#force-printing_scriptlets
      },
      methods: {
        runServerFunction: function () {
          // GAS側の関数を実行します
          google.script.run.myFunction();
        },
      }
    })
  </script>
</body>
</html>

実行イメージと解説

HelloWorld.gif
まずfunction onOpen()で、ドキュメントを開いた際にサイドバーを実行できるようなメニューを作成します。
ドキュメントをリロードすると拡張メニューが現れるので、追加されたメニューから、サイドバーとダイアログでフロントエンドのhtmlを実行します。
両方ともサーバーサイドの myFunction が実行され、シートの最終行に文字列が挿入された様子がわかるかと思います。

サイドバー側にパラメータを渡す方法はいくつか考えられますが、この例ではforce-printing_scriptletsで渡しています。
https://developers.google.com/apps-script/guides/html/templates#force-printing_scriptlets

まずサイドバー生成前の htmlTemplate.evaluate() で"Hello GAS World!"が表示用のHTMLに埋め込まれて作成されます。
そしてブラウザの表示時にVue.jsの機能で {{message}} の部分が置き換えられています
(動画を見ると置き換えられる様子が確認できるかと思います)
GASから静的なパラメータをサイドバー側送るのであれば、このやり方が一般的かと思います。

サイドバー側からシートを操作するのは、おなじみの google.script.run となります。
この例では文字列を1行挿入するだけなので、命令を送りっぱなしで放置しています。
戻り値や例外を処理する必要があればwithSuccessHandlerから呼び出す必要がありますが、今回は割愛しています。

サイドバーを使う際の注意点

今回はSpreadsheetでサイドバーを作成しましたが、DocumentやSlideも上記のような手法で作成可能です。
思いのほか簡単に作成できるサイドバーですが、使ってみると問題点も浮かんできます。
以下に個人的に注意すべき点を幾つか並べてみました。

その1:幅が狭い

サイドバーですがとにかく幅が狭いです!
これは画面解像度関係なく、横幅300pxで固定でどうやっても変更できません。
このため、大き目のUIを持つフレームワークを使用すると、とても残念な見た目になります。
デフォルト設定で崩れた見た目をCSS等で適切にカスタマイズするのは「お手軽」とは言えないので、フレームワークの特性は見極める必要があります。

その2:共有のためのアクセス権限設定

ツールが完成した時、シートとサイドバーをチーム内で使用する事になったとします。
その際は共有設定を弄る事となりますが、アクセスするユーザーは単にシートにアクセスできるだけではダメで

  • Googleにログインしている
  • 「アクセスできるユーザー」に登録されている
  • 編集権限を持っている

の3点セットが必須となります。
(加えてGSUITEユーザーであれば組織内の共有権限が問題になることもあります)
つまり「匿名カピバラ」や「匿名ラマ」等の野生ユーザーは、スクリプトの実行権限がないのでサイドバーを選ぶメニューすら表示されないです。

匿名の動物アイコンが表示される場合
https://support.google.com/docs/answer/2494888?hl=ja

その3:APIトークン等の秘密にすべき情報の扱い

これはサイドバー云々の話ではなく、GASを仕込んだGoogleドキュメント全般での話になります。
例えばAPIトークンを使って外部サービスと連動させる場合、GAS側に固定値として埋め込みたくなるケースは多いかと思います。
ところが、ドキュメントを編集可能なユーザーは、それに紐づくスクリプトのソースを覗く事ができるため、アクセス可能なユーザーは誰であれトークンを知ることができてしまいます。
コントロール可能なチーム内で共有する場合はともかく、組織内で展開する場合は、こういった情報を埋め込むべきでは無いです。
その場合、それ専用のGASアプリを作るなり、GASライブラリを作るなりして、スクリプト自体を隠蔽する必要が出てきます。

完成品の簡単な説明

以上を踏まえた上で、冒頭でお見せしたこちらの説明をします
Sidebar概要.gif

まず、今回の作例では、BuefyというVue.js用UIコンポーネントを使用しました。
https://buefy.github.io

BuefyのベースとなっているBulmaはFlexboxベースのレスポンシブなCSSフレームワークとなっており、300pxという狭い横幅を上手く使う事ができます。
またBuefyが提供するコンポーネント群が豊富で、Spreadsheetの機能を補強するにはうってつけです。
Buefyはフォントアイコンもサポートしておりデフォルト設定ではMaterialdesigniconsとなっているため、特に拘りもなかったのでこちらもCDNから読み込んでいます。

また日付処理が面倒だったのでmoment.jsも使っています。
そもそもGoogleSpreadsheetに軽快な読み込みは期待していないので、ラクできそうなライブラリはガンガン使った方が良いです。

code.gs
/**
 * HTML側から呼ばれる関数
 */
function setCell(json) {
  var param = JSON.parse(json);
  var range = SpreadsheetApp.getActiveSheet().getActiveRange();

  if ( param.bgcolor ) range.setBackground(param.bgcolor);
  if ( param.fontcolor ) range.setFontColor(param.fontcolor);
  if ( param.note ) range.setNote(param.note);
  if ( param.value ) range.setValue(param.value);
}

/**
 * Spreadsheetを開いたときに拡張メニューを追加する
 */
function onOpen() {
  SpreadsheetApp.getUi()
      .createMenu('拡張メニュー')
      .addItem('サイドバーを開く', 'showSidebar')
      .addToUi();
}

/**
 * GASプロジェクト内にあるファイルを読み込みテンプレートを作成する
 *
 * @param {String} filename
 * @return {Object} HtmlOutput
 *
 * HtmlOutput:
 *   https://developers.google.com/apps-script/reference/html/html-output
 */
function createHtmlOutput(filename) {

  var htmlTemplate = HtmlService.createTemplateFromFile(filename);
  // https://developers.google.com/apps-script/reference/html/html-template

  // 登録した値から最終的なHtmlOutputを生成し返す
  return htmlTemplate.evaluate();
}

/**
 * サイドバーを開く
 */
function showSidebar() {

  // 読み込みの際はファイル名の末尾に .html が追加される
  var ui = createHtmlOutput('index')
      .setTitle('拡張サイドバー')
      .setSandboxMode(HtmlService.SandboxMode.IFRAME);

  SpreadsheetApp.getUi().showSidebar(ui);  
}
index.html
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>拡張サイドバー</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/buefy@0.7.1/dist/buefy.min.css">
    <link rel="stylesheet" href="https://cdn.materialdesignicons.com/2.0.46/css/materialdesignicons.min.css">
    <style>
        html {
            overflow-y: auto;
        }
    </style>
</head>

<body>
    <div id="app">
        <b-tabs v-model="activeTab" position="is-centered">
            <b-tab-item icon="format-paint" label="着色">
                <a v-for="(btn, index) in buttons"
                   @click="fillActiveSpreadsheetCell(index)"
                   :key="index"
                   :class="btnClass(index)"
                   :style="{'background-color': btn.color}">
                   {{btn.name}}
                </a>
            </b-tab-item>

            <b-tab-item icon="message-text" label="メモ">
                <div class="colunm">
                    <b-field label="表題">
                        <b-select v-model="form.title" expanded required placeholder="title">
                            <option>確認をお願いします</option>
                            <option>折り返し連絡ください</option>
                            <option>至急対応をしてください</option>
                            <option>ご意見をください</option>
                        </b-select>
                    </b-field>
                    <b-field label="メッセージ">
                        <b-input v-model="form.message" expanded required type="textarea" placeholder="Message"></b-input>
                    </b-field>
                    <button class="button is-info is-large is-fullwidth" :disabled="!isSubmittable" @click="submitNote">
                        <b-icon icon="comment-text" size="is-small"></b-icon>
                        <span>選択セルにメモる</span>
                    </button>
                </div>
            </b-tab-item>

            <b-tab-item icon="calendar" label="日付">
              <b-datepicker inline v-model="date"
                :day-names="['日','月','火','水','木','金','土']"
                :month-names="['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']">
              </b-datepicker>
              <button class="button is-info is-large is-fullwidth" @click="submitDate">
              <b-icon icon="pencil" size="is-small"></b-icon>
                <span>{{formatDate}}</span>
              </button>
            </b-tab-item>
        </b-tabs>
    </div>
</body>

<script src="https://jp.vuejs.org/js/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/buefy@0.7.1/dist/buefy.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.22.2/moment.min.js"></script>

<script>
    Vue.use(Buefy.default)
    var app = new Vue({
        el: '#app',

        data: {
            activeTab: 0,
            buttons: [
                { color: '#FFFFFF', font: 'has-text-black', name: "クリア - reset" },
                { color: '#ff0000', font: 'has-text-white', name: "赤 - red" },
                { color: '#0000ff', font: 'has-text-white', name: "青 - blue" },
                { color: '#00ff00', font: 'has-text-black', name: "緑 - green" },
                { color: '#808080', font: 'has-text-white', name: "灰色 - gray" },
                { color: '#000000', font: 'has-text-white', name: "黒 - black" },
            ],
            form: {
                title: '',
                message: ''
            },
            date: new Date()
       },
        computed: {
            isSubmittable() {
                if (!this.form.title.length) return false;
                if (!this.form.message.length) return false;
                return true;
            },
            formatDate() {
              return moment(this.date).format('YYYY-MM-DD');
            }
        },
        methods: {
            toast(message) {
                this.$toast.open({
                    message: message,
                    type: 'is-success'
                });
            },

            btnClass(index) {
                const result = {
                    'button': true,
                    'is-large': true,
                    'is-fullwidth': true,
                };
                result[this.buttons[index].font] = true;

                return result;
            },

            fillActiveSpreadsheetCell(index) {
                var payload = {
                  bgcolor: this.buttons[index].color,
                  fontcolor: (this.buttons[index].font == 'has-text-white') ? '#ffffff' : '#000000'
                };

                google.script.run.setCell(JSON.stringify(payload));
            },

            submitNote() {
              var message = '表題:' + this.form.title + '\n投稿日:' + moment(this.date).format('YYYY-MM-DD') + '\n\n' + this.form.message;
              var payload = {
                note: message
              };
              google.script.run.setCell(JSON.stringify(payload));
              this.toast('セルにメモを書き込みました');
              this.form.title = '';
              this.form.message = '';
            },

            submitDate() {
              var payload = {
                value: moment(this.date).format('YYYY-MM-DD')
              };
              google.script.run.setCell(JSON.stringify(payload));
            },
        }
    })
</script>

まとめ

いかがだったでしょうか?
今回はGASの作例というよりフロントエンド寄りな記事になってしまいましたが、GASを実行可能になるボタンや、入力フォームを手軽に作れるというのは、色々活用範囲は広いかと思います。
皆さまもオレオレサイドバーを作って楽しんでみてください。

tryforth
よろしくー
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした