GAS + スプレッドシートでQ&Aサイトつくってみた

# はじめに

GAS（Google Apps Script) + スプレッドシートを使ってQ&Aサイトを作ってみる

Googleフォームで問い合わせを受け付けて、スプレッドシートで問い合わせ管理をしているケースがあると思います。
そのスプレッドシートを公開して同じような質問がないか確認してもらってから問い合わせをしてもらうという方法もあるでしょう。
それだと、ここで話が終わってしまうので、スプレッドシートの内容をGASでWebアプリとしてつくってみたいと思います。

GASでどんなことができるのか理解するのに役立てられればと思います。

# 開発準備

お好みでclaspを使ってtypescriptで実装や使いやすいエディタで実装するのが良いと思います。
ここでは、claspやtypescriptに関しては触れません。

Google Apps Scriptの新しいプロジェクトから開始します。
ファイル名、プロジェクト名などは任意の名前に変更します。

ここでは、app.gsとしました。
その他に、css.html、index.html、js.htmlを用意します。

GASでつくるウェブサイトでは、拡張子htmlのファイルしか使えません。cssやjavascriptもhtmlとする必要があります。
cssやjavascriptをhtmlの別ファイルとしてincludeして、cssファイルやjavascriptファイルみたいに利用している感じです。

それでは実際に作っていきます。

# 開発

## サーバー処理

app.gsにサーバー側の処理としてスプレッドシートからデータを取得し、hashにデータを詰め込んで、view側（index.html)にデータを渡す処理を実装します。

今回はアロー関数を使っていますが、functionで実装もできます。

doGetにGet Requestに対するResponse処理を実装します。querystringのパラメータも受け取れます。今回は利用しませんがサンプルを記載します。

viewとなるhtmlファイルを指定し、任意のhashパラメータ（今回はlist）を設定します。

responseとしてevaluateをreturnします。このあたりはそう言うものなんだなぁ程度で大丈夫です。

```javascript
/*
  https://developers.google.com/apps-script/guides/web
{
  "queryString": "username=jsmith&age=21",
  "parameter": {
    "username": "jsmith",
    "age": "21"
  },
  "contextPath": "",
  "parameters": {
    "username": [
      "jsmith"
    ],
    "age": [
      "21"
    ]
  },
  "contentLength": -1
}
*/
const doGet = (e) => {
  const htmlOutput = HtmlService.createTemplateFromFile('index');
  htmlOutput.list = getFAQs();

  return htmlOutput.evaluate();
};

const getFAQs = () => {
  const url = 'https://docs.google.com/spreadsheets/hogehoge/edit';
  const sheet = SpreadsheetApp.openByUrl(url);
  const lastRow = sheet.getLastRow();

  const records = sheet.getRange(`A2:J${lastRow}`).getValues();

  let results = [];
  for (let i = 0; i < records.length; i++) {
    if (records[i][7] != '完了') continue;
    if (records[i][8] == '') continue;

    results.push({
      created_at: records[i][1],
      question: htmlEncode(records[i][2]).replace(/\r?\n/g, '<br />'),
      answer: htmlEncode(records[i][8]).replace(/\r?\n/g, '<br />'),
    });
  }

  // 最新のものから順に表示
  return results.reverse();
};

const htmlEncode = (text) => {
  const entityMap = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;',
    '/': '&#x2F;',
    '`': '&#x60;',
    '=': '&#x3D;',
  };
  return String(text).replace(/[&<>"'`=\/]/g, (s) => {
    return entityMap[s];
  });
};

```

getFAQs関数は、スプレッドシートで管理されている問い合わせ、回答、ステータス（完了、対応中、未対応）、受付日時などから情報を取得し、hashで返却します。
ステータスが完了になっているもの、回答が書かれているものだけを対象としました。

```javascript
    if (records[i][7] != '完了') continue;
    if (records[i][8] == '') continue;
```

改行コードを\<br\>に変換して見やすくします。後述しますが、htmlのタグを有効にするには `<?!= list[i].answer ?>` と記載します。一方でこれをやってしまうと、
\< \> も自動でエンコードされません。忘れずにhtmlエンコードする必要があります。
それがhtmlEncode関数になります。

最新のものから表示したかったので、returnするときに配列を逆順にしています。
Googleフォームで登録するとスプレッドシートに行が追加されていくので、配列のreverseで最新のものから並ぶはずです。

以上でサーバー側の処理は終了です。

## view (index.html)

viewとなるhtmlのテンプレートファイルです。

特に難しいところはありません。css.htmlとjs.htmlをロードする。
外部のCSSやJSを利用することができるということを覚えておけば良いでしょう。

今回はお手軽にアコーディオンを実装できるHTML5で追加されたdetail summaryタグを使います。

サーバー側で作った、listを宣言なく使うことができます。

`<?= hoge ?>` はhtmlエンコードします。

`<?!= hoge ?>` はhtmlエンコードしません。

```html
<!DOCTYPE html>
<html>

  <head>
    <base target="_top">
    <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>
    <link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.5/css/bootstrap.css">
    <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?>
  </head>

  <body>
    <div id="app">
      <h2>FAQ</h2>
      <p><input placeholder="キーワード検索" name="keyword"/></p>

      <? for(let i = 0; i < list.length; i++){ ?>
      <details>
        <summary><span class="summary_inner">Q. <?!= list[i].question ?><span class="icon"></span></span></summary>
        <div class="content js-content">
          <div class="content_inner">A. <?!= list[i].answer ?></div>
        </div>
      </details>
      <? } ?>
    </div>
  </body>

</html>
```

以上で動作しますが、もう少し、webサイトらしい動きをつけるために、cssとjavascriptを実装していきます。

今回は、jQueryの存在に感謝しつつ、jQueryを使わずに実装してみたいと思います。

## javascript (js.html)

GASでは、htmlファイルにjavascriptを書くことになるので、`<script></script>` が必要になります。

FAQが並んでいるだけだと探しづらいので検索ボックスにキーワードを入力するとリアクティブに絞り込まれるようにも実装します。

アコーディオンもjQueryを使わずに実装しました。（詳細はコード内コメントを参照してください。）なかなか大変なので、jQueryを利用するのが良いですね。

キーワード検索は、正規表現を駆使してなるべく軽くなるように作ったつもりです。

キーワードのハイライト処理は、テキストボックスから抜けた時(blur)に実行されるようにしました。こっちのほうが軽いかな？と思い。お好みで実装変更してください。

```javascript
<script>
document.addEventListener('DOMContentLoaded', () => {
  setUpAccordion();
  // キーワードによる絞り込みイベントをバインド
  const input = document.querySelector('input[name="keyword"]');
  input.addEventListener('input', searchWord);
  // キーワードをハイライトするイベントをバインド
  input.addEventListener('blur', highlightKeyword)
});

/**
 * ブラウザの標準機能(Web Animations API)を使ってアコーディオンのアニメーションを制御します
 */
const setUpAccordion = () => {
  const RUNNING_VALUE = 'running'; // アニメーション実行中のときに付与する予定のカスタムデータ属性の値
  const IS_OPENED_CLASS = 'is-opened'; // アイコン操作用のクラス名

  const details = document.querySelectorAll('.js-details');
  details.forEach((element) => {
    const summary = element.querySelector('.js-summary');
    const content = element.querySelector('.js-content');

    summary.addEventListener('click', (event) => {
      // デフォルトの挙動を無効化
      event.preventDefault();

      // 連打防止用。アニメーション中だったらクリックイベントを受け付けないでリターンする
      if (element.dataset.animStatus === RUNNING_VALUE) return;

      if (element.open) {
        // アコーディオンを閉じるときはクラスを取り除く
        element.classList.toggle(IS_OPENED_CLASS);

        // アニメーションを実行
        const closingAnim = content.animate(closingAnimKeyframes(content), animTiming);
        element.dataset.animStatus = RUNNING_VALUE;

        // アニメーションの完了後にopen属性を取り除く
        closingAnim.onfinish = () => {
          element.removeAttribute('open');
          element.dataset.animStatus = '';
        };

      } else {
        // アコーディオンを開くときにopen属性を付与
        element.setAttribute('open', 'true');

        // アイコン操作用クラスを付与
        element.classList.toggle(IS_OPENED_CLASS);

        // アニメーションを実行
        const openingAnim = content.animate(openingAnimKeyframes(content), animTiming);
        element.dataset.animStatus = RUNNING_VALUE;

        // アニメーション完了後にアニメーション実行中用の値を取り除く
        openingAnim.onfinish = () => {
          element.dataset.animStatus = '';
        };
      }
    });
  });
};

/**
 * アニメーションの時間とイージング
 */
const animTiming = {
  duration: 400,
  easing: 'ease-out',
};

/**
 * アコーディオンを閉じるときのキーフレーム
 */
const closingAnimKeyframes = (content) => [
  {
    height: content.offsetHeight + 'px', // height: "auto"だとうまく計算されないため要素の高さを指定する
    opacity: 1,
  }, {
    height: 0,
    opacity: 0,
  }
];

/**
 * アコーディオンを開くときのキーフレーム
 */
const openingAnimKeyframes = (content) => [
  {
    height: 0,
    opacity: 0,
  }, {
    height: content.offsetHeight + 'px',
    opacity: 1,
  }
];

/**
 * キーワードで絞り込む
 */
 const searchWord = (e) => {
  // 全角スペースを半角にして、前後trimして、半角スペースでスプリット
  const searchTexts = e.target.value.replace(/　/g, ' ').trim().split(' ');
  // 正規表現 and検索 大文字小文字を区別しない、出現順も気にしないで検索
  let regex = '';
  for(let i = 0; i < searchTexts.length; ++i) {
    if(searchTexts[i].length == 0) continue;
    regex += `(?=.*${searchTexts[i]})`;
  }

  // 正規表現に一致したQAのみ表示
  const regexp = new RegExp(regex, 'gi');
  document.querySelectorAll('details').forEach(element => {
    element.style.display = regexp.test(element.innerText) ? 'block' : 'none';
  });
};

/**
 * キーワードをハイライトする
 */
const highlightKeyword = (e) => {
  // ハイライトされているものをすべて削除
  document.querySelectorAll('b.search-highlight').forEach(element => { unwrap(element); });

  // 全角スペースを半角にして、前後trimして、半角スペースでスプリット
  const searchTexts = e.target.value.replace(/　/g, ' ').trim().split(' ');
  let regex = '';
  for(let i = 0; i < searchTexts.length; ++i) {
    if(searchTexts[i].length == 0) continue;
    if(regex.length > 0) regex += '|';
    regex += searchTexts[i];
  }
  // 検索キーワードが指定されていないときは終了
  if(regex.length == 0) return;

  regex = `(${regex})`;
  const regexp = new RegExp(regex, 'gi');
  document.querySelectorAll('details').forEach(element => {
    element.innerHTML = element.innerHTML.replace(regexp, '<b class="search-highlight">$1</b>');
  });
};

/**
 * jqueryのunwrapに相当
 */
const unwrap = (element) => {
  while(element.firstChild) {
    element.parentNode.insertBefore(element.firstChild, element);
  }
  element.remove();
};
</script>
```

実装してみて・・・jQueryで良いかと思った。

## css (css.html)

cssも載せておきます。

```css
<style type="text/css">
  #app p input { width: 80%; height: 40px; border-radius: 10px; }
  #app { padding: 5px 40px 100px; }
  #app h3 { margin-top: 50px; }
  summary {
    /* display: list-item;以外を指定してデフォルトの三角形アイコンを消します */
    display: block;
  }
  details { margin: 10px; }
  details .search-highlight { background: linear-gradient(transparent 10%, #FF9100 10% 90%, transparent 90%); border-radius: 10px; }
  #app details:nth-child(odd) { background: #FFFFF0; }
  #app details:nth-child(even) { background: #F5FFFA; }
  summary::-webkit-details-marker {
    /* Safariで表示されるデフォルトの三角形アイコンを消します */
    display: none;
  }

  .summary_inner{
    cursor: pointer;
    display: inherit;
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
    padding: 16px 40px 16px 24px;
    border: 1px solid #d2beff;
    font-weight: bold;
    color: #002255;
    position: relative;
    border-radius: 10px;
  }

  /* --------アイコンを作ります-------- */
  .icon {
    display: block;
    position: absolute;
    top: 50%;
    transform: translate(0, -50%);
    width: 24px;
    margin-left: 6px;
    flex-shrink: 0;
    transform-origin: center 43%;
    transition: transform 0.4s;
    right: 0.5%;
  }

  /* アコーディオンが開いた時のスタイル */
  details[open] .icon {
    transform: rotate(180deg);
  }

  /* アコーディオンが開いた時にアイコンをアニメーションさせます */
  details[open] .summary_inner::after {
    animation: 0.4s open-animation forwards;
  }

  /* アイコンのバーのスタイル */
  .icon::before,
  .icon::after {
    content: "";
    position: absolute;
    display: block;
    width: 15px;
    height: 3px;
    background-color: #7050ff;
  }

  .icon::before {
    left: 0;
    transform: rotate(45deg);
  }

  .icon::after {
    right: 0;
    transform: rotate(-45deg);
  }

  @keyframes close-animation {
    from {
      transform: rotate(180deg);
    }

    to {
      transform: rotate(0deg);
    }
  }

  @keyframes open-animation {
    from {
      transform: rotate(0deg);
    }

    to{
      transform: rotate(180deg);
    }
  }

</style>
```

# デプロイ

デプロイ、新しいデプロイからウェブアプリを選択してデプロイする。

2回目以降は、新しいデプロイではなくデプロイを管理からバージョンを新しいバージョンに変更してデプロイしていく。

新しいデプロイでデプロイするとURLが変わってしまいます。