GASでいい感じのHTMLフォームを作成する

# はじめに

Googleフォームって便利ですよね。
でもGoogleフォームだけじゃ満足できな時もありますよね。
そんな時に別のフォームサービスを使うのもありですが、
自分でフォームを作るのも一つの手だと思います。
今回はそんな一例を示すことができればよいと思います。

:::note info
基本的な内容は以前に書いたこの記事で確認できます。
[GASでWebアプリ作るときのTips - Qiita](https://qiita.com/dadadaiiiiiii/items/beb33b838d0516cade05)
:::

# 簡単なフォームの作り方

今回はフォームで記入した情報をスプレッドシートに記録する形にしようと思います。
手順としては次の通りです。
1. スプレッドシートを作成
1. スプレッドシートのメニューの`拡張機能`>`Apps Script`からGAS(Google Apps Script)を作成
1. 以下のスクリプトを記述

```javascript
/**
 * GETリクエストに応答し、HTMLページを表示します。
 *
 * @returns {HtmlOutput} レンダリングされたHTMLページ
 */
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('サンプルフォーム');
}

/**
 * フォームから送信されたデータを処理し、スプレッドシートに保存します。
 *
 * @param {Object} formData - フォームから送信されたデータオブジェクト
 * @returns {string} ユーザーに表示するメッセージ
 */
function submitForm(formData) {
  // アクティブなスプレッドシートを取得
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  // '回答'シートを取得（存在しない場合は作成）
  let sheet = ss.getSheetByName('回答');
  if (!sheet) {
    sheet = ss.insertSheet('回答');
    // ヘッダー行を作成
    const headers = Object.keys(formData);
    headers.unshift('タイムスタンプ');
    sheet.appendRow(headers);
  }

  // シートのヘッダー行を取得
  const headers = sheet.getDataRange().getValues()[0];
  const rowData = [];

  // タイムスタンプを追加
  rowData.push(new Date());

  // フォームデータをヘッダー順に並べる
  for (let i = 1; i < headers.length; i++) {
    const header = headers[i];
    let value = formData[header];

    // 値が配列の場合、カンマで結合
    if (Array.isArray(value)) {
      value = value.join(', ');
    }

    // 未定義の場合は空文字に
    rowData.push(value !== undefined && value !== null ? value : '');
  }

  // データをシートに追加
  sheet.appendRow(rowData);

  return "フォームが送信されました。ありがとうございます！";
}
```

以下はHTMLファイルで作成（名前はindex)
```html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>サンプルフォーム</title>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
  <base target="_top">
  <style>
    body {
      background-color: #f8f9fa;
    }
    .form-container {
      max-width: 600px;
      margin: 50px auto;
    }
    .form-header {
      margin-bottom: 30px;
    }
    .btn-submit {
      width: 100%;
    }
    /* スピナーを中央に配置 */
    .spinner-overlay {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(255, 255, 255, 0.7);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 9999;
    }
    .spinner-overlay.hidden {
      display: none;
    }
  </style>
</head>
<body>
  <div class="container form-container">
    <h2 class="text-center form-header">サンプルフォーム</h2>
    <form id="myForm">
      <!-- テキスト入力 -->
      <div class="mb-3">
        <label for="textInput" class="form-label">テキスト入力</label>
        <input type="text" class="form-control" id="textInput" name="textInput" placeholder="テキストを入力してください">
      </div>
      <!-- Eメール入力 -->
      <div class="mb-3">
        <label for="emailInput" class="form-label">Eメール入力</label>
        <input type="email" class="form-control" id="emailInput" name="emailInput" placeholder="メールアドレスを入力してください">
      </div>
      <!-- パスワード入力 -->
      <div class="mb-3">
        <label for="passwordInput" class="form-label">パスワード入力</label>
        <input type="password" class="form-control" id="passwordInput" name="passwordInput" placeholder="パスワードを入力してください">
      </div>
      <!-- 数値入力 -->
      <div class="mb-3">
        <label for="numberInput" class="form-label">数値入力</label>
        <input type="number" class="form-control" id="numberInput" name="numberInput" placeholder="数値を入力してください">
      </div>
      <!-- 日付入力 -->
      <div class="mb-3">
        <label for="dateInput" class="form-label">日付入力</label>
        <input type="date" class="form-control" id="dateInput" name="dateInput">
      </div>
      <!-- 時刻入力 -->
      <div class="mb-3">
        <label for="timeInput" class="form-label">時刻入力</label>
        <input type="time" class="form-control" id="timeInput" name="timeInput">
      </div>
      <!-- 選択メニュー -->
      <div class="mb-3">
        <label for="selectInput" class="form-label">選択メニュー</label>
        <select class="form-select" id="selectInput" name="selectInput">
          <option value="">選択してください</option>
          <option value="1">オプション1</option>
          <option value="2">オプション2</option>
          <option value="3">オプション3</option>
        </select>
      </div>
      <!-- ラジオボタン -->
      <div class="mb-3">
        <label class="form-label">ラジオボタン</label>
        <div class="form-check">
          <input class="form-check-input" type="radio" name="radioInput" id="radioOption1" value="option1" checked>
          <label class="form-check-label" for="radioOption1">
            オプション1
          </label>
        </div>
        <div class="form-check">
          <input class="form-check-input" type="radio" name="radioInput" id="radioOption2" value="option2">
          <label class="form-check-label" for="radioOption2">
            オプション2
          </label>
        </div>
      </div>
      <!-- チェックボックス -->
      <div class="mb-3">
        <label class="form-label">チェックボックス</label>
        <div class="form-check">
          <input class="form-check-input" type="checkbox" name="checkboxInput[]" id="checkboxOption1" value="option1">
          <label class="form-check-label" for="checkboxOption1">
            オプション1
          </label>
        </div>
        <div class="form-check">
          <input class="form-check-input" type="checkbox" name="checkboxInput[]" id="checkboxOption2" value="option2">
          <label class="form-check-label" for="checkboxOption2">
            オプション2
          </label>
        </div>
      </div>
      <!-- テキストエリア -->
      <div class="mb-3">
        <label for="textareaInput" class="form-label">テキストエリア</label>
        <textarea class="form-control" id="textareaInput" name="textareaInput" rows="3" placeholder="コメントを入力してください"></textarea>
      </div>
      <!-- ボタン -->
      <button type="button" class="btn btn-primary btn-submit" onclick="submitForm()">送信</button>
    </form>
  </div>

  <!-- スピナーのオーバーレイ -->
  <div id="spinnerOverlay" class="spinner-overlay hidden">
    <div class="spinner-border text-primary" role="status">
      <span class="visually-hidden">読み込み中...</span>
    </div>
  </div>

  <script>
    function submitForm() {
      const form = document.getElementById('myForm');
      const formData = new FormData(form);

      // フォームを無効化
      Array.from(form.elements).forEach(element => {
        element.disabled = true;
      });

      // スピナーを表示
      document.getElementById('spinnerOverlay').classList.remove('hidden');

      // フォームデータをオブジェクトに変換
      const data = {};
      formData.forEach((value, key) => {
        if (data[key]) {
          if (Array.isArray(data[key])) {
            data[key].push(value);
          } else {
            data[key] = [data[key], value];
          }
        } else {
          data[key] = value;
        }
      });

      google.script.run.withSuccessHandler(function(response) {
        alert(response);
        form.reset();

        // フォームを有効化
        Array.from(form.elements).forEach(element => {
          element.disabled = false;
        });

        // スピナーを非表示
        document.getElementById('spinnerOverlay').classList.add('hidden');
      }).withFailureHandler(function(error) {
        alert('エラーが発生しました: ' + error.message);

        // フォームを有効化
        Array.from(form.elements).forEach(element => {
          element.disabled = false;
        });

        // スピナーを非表示
        document.getElementById('spinnerOverlay').classList.add('hidden');
      }).submitForm(data);
    }
  </script>
</body>
</html>
```

ちょっと長いですが、これである程度の種類のインプットをカバーしたフォームが作れます。
スタイルとかは好みに合わせて変えてください（上記のHTMLはBootstrap使ってます）
あとは要件に合わせて好きなように修正してください。

# フォーム入力者のメールアドレスを取得

Googleのアカウントを持っている人が来る前提（社内利用のみなど）ですが、
フォームの入力者のメールアドレスを取得することができます。
その場合はdoGet関数を下記のように編集します。

```javascript
function doGet() {
  // アクティブユーザーのメールアドレスを取得
  const email = Session.getActiveUser().getEmail();

  // テンプレートを作成し、メールアドレスを渡す
  const template = HtmlService.createTemplateFromFile('index');
  template.email = email;

  return template.evaluate()
    .setTitle('サンプルフォーム');
}
```

HTMLファイルの方も下記のようにEメール入力の部分を修正します。
valueで```<?= email ?>```とすることでテンプレートのスクリプトレットが利用できます。
```html
      <!-- Eメール入力 -->
      <div class="mb-3">
        <label for="emailInput" class="form-label">Eメール入力</label>
        <input type="email" class="form-control" id="emailInput" name="emailInput" placeholder="メールアドレスを入力してください" value="<?= email ?>">
      </div>
```

# 日付と時刻をアクセスした時間に初期設定

日付と時刻もアクセスした時間に設定してみましょう。
日付とかは工夫すれば月初や月末、何日前、後など工夫できると思います。
時刻も一緒かな？

```javascript
function doGet() {
  // アクティブユーザーのメールアドレスを取得
  const email = Session.getActiveUser().getEmail();

  // 現在の日時を取得
  const now = new Date();
  
  // 日付と時刻をフォーマット
  const year = now.getFullYear();
  const month = ('0' + (now.getMonth() + 1)).slice(-2);
  const day = ('0' + now.getDate()).slice(-2);
  const formattedDate = `${year}-${month}-${day}`;

  const hours = ('0' + now.getHours()).slice(-2);
  const minutes = ('0' + now.getMinutes()).slice(-2);
  const formattedTime = `${hours}:${minutes}`;

  // テンプレートを作成し、データを渡す
  const template = HtmlService.createTemplateFromFile('index');
  template.email = email;
  template.date = formattedDate;
  template.time = formattedTime;

  return template.evaluate()
    .setTitle('サンプルフォーム');
}
```

```html
      <!-- 日付入力 -->
      <div class="mb-3">
        <label for="dateInput" class="form-label">日付入力</label>
        <input type="date" class="form-control" id="dateInput" name="dateInput" value="<?= date ?>">
      </div>
      <!-- 時刻入力 -->
      <div class="mb-3">
        <label for="timeInput" class="form-label">時刻入力</label>
        <input type="time" class="form-control" id="timeInput" name="timeInput" value="<?= time ?>">
      </div>
```

# プルダウンやラジオボタン、チェックボックスをスプレッドシートから取得

プルダウンやラジオボタン、チェックボックスの選択項目もスプレッドシートで管理できるようにしましょう。
スプレッドシートにそれぞれの名前のシートを追加し、項目名と値を追加します。（画像参照）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2888091/54b9938b-f74d-bebd-83fe-e70c0b6ee38a.png)


doGet関数を下記のように編集します。
```javascript
function doGet() {
  // アクティブユーザーのメールアドレスを取得
  const email = Session.getActiveUser().getEmail();

  // 現在の日時を取得
  const now = new Date();
  
  // 日付と時刻をフォーマット
  const formattedDate = Utilities.formatDate(now, "Asia/Tokyo", "yyyy-MM-dd");
  const formattedTime = Utilities.formatDate(now, "Asia/Tokyo", "HH:mm");

  // スプレッドシートからオプションを取得する関数
  function getOptions(sheetName) {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName(sheetName);
    if (!sheet) return [];
    
    const dataRange = sheet.getDataRange();
    const data = dataRange.getValues();
    
    // ヘッダー行を除くデータを取得
    const options = [];
    for (let i = 1; i < data.length; i++) {
      const row = data[i];
      options.push({
        name: row[0],
        value: row[1]
      });
    }
    return options;
  }

  // 各シートからオプションを取得
  const pulldownOptions = getOptions('プルダウン');
  const radioOptions = getOptions('ラジオボタン');
  const checkboxOptions = getOptions('チェックボックス');

  // テンプレートを作成し、データを渡す
  const template = HtmlService.createTemplateFromFile('index');
  template.email = email;
  template.date = formattedDate;
  template.time = formattedTime;
  template.pulldownOptions = pulldownOptions;
  template.radioOptions = radioOptions;
  template.checkboxOptions = checkboxOptions;

  return template.evaluate()
    .setTitle('サンプルフォーム');
}
```

次にhtmlは該当する部分をそれぞれ下記のように編集します。

```html
<!-- 選択メニュー -->
<div class="mb-3">
  <label for="selectInput" class="form-label">選択メニュー</label>
  <select class="form-select" id="selectInput" name="selectInput">
    <option value="">選択してください</option>
    <? for (let i = 0; i < pulldownOptions.length; i++) { ?>
      <option value="<?= pulldownOptions[i].value ?>"><?= pulldownOptions[i].name ?></option>
    <? } ?>
  </select>
</div>
```
```html
<!-- ラジオボタン -->
<div class="mb-3">
  <label class="form-label">ラジオボタン</label>
  <? for (let i = 0; i < radioOptions.length; i++) { ?>
    <div class="form-check">
      <input class="form-check-input" type="radio" name="radioInput" id="radioOption<?= i ?>" value="<?= radioOptions[i].value ?>" <?= i === 0 ? 'checked' : '' ?>>
      <label class="form-check-label" for="radioOption<?= i ?>">
        <?= radioOptions[i].name ?>
      </label>
    </div>
  <? } ?>
</div>
```
```html
<!-- チェックボックス -->
<div class="mb-3">
  <label class="form-label">チェックボックス</label>
  <? for (let i = 0; i < checkboxOptions.length; i++) { ?>
    <div class="form-check">
      <input class="form-check-input" type="checkbox" name="checkboxInput[]" id="checkboxOption<?= i ?>" value="<?= checkboxOptions[i].value ?>">
      <label class="form-check-label" for="checkboxOption<?= i ?>">
        <?= checkboxOptions[i].name ?>
      </label>
    </div>
  <? } ?>
</div>

```

# インプットにサジェストを出す

datalistというタグを使えばインプット要素にサジェストを出すことができます。
（Firefoxは対応していない見たいです）

HTML下記の部分を次のように書き換えます。

```html
      <!-- テキスト入力（datalistを使用） -->
      <div class="mb-3">
        <label for="textInput" class="form-label">テキスト入力</label>
        <input type="text" class="form-control" list="datalistOptions" id="textInput" name="textInput" placeholder="テキストを入力してください">
        <datalist id="datalistOptions">
          <option value="オプション1">
          <option value="オプション2">
          <option value="オプション3">
          <option value="オプション4">
          <option value="オプション5">
        </datalist>
      </div>
```

時間をdatalistに修正
```html
      <!-- 時間入力（datalistを使用） -->
      <div class="mb-3">
        <label for="timeInput" class="form-label">時刻入力</label>
        <input type="time" class="form-control" list="timeOptions" id="timeInput" name="timeInput" placeholder="時間を選択してください">
        <datalist id="timeOptions">
          <option value="09:00">
          <option value="10:00">
          <option value="11:00">
          <option value="12:00">
          <option value="13:00">
          <option value="14:00">
          <option value="15:00">
          <option value="16:00">
          <option value="17:00">
        </datalist>
      </div>
```
これでサジェスト有りのinput要素を作ることができます。
ブラウザが限定されますが、社内利用のGASのフォームなんかだと
利用しやすいのではないでしょうか？
サジェストはしますが任意の値も入力できるので厳密に値を指定したい場合は
利用を避けてもらう方が良いかもです。
（Choices.jsとかで検索できるセレクトBOXは実装できます。今回は素のJSのみ紹介です。）

# ファイルアップロード

## 必要な準備

まず、Googleドライブにアップロードされたファイルを保存するフォルダを作成しておきましょう。
そのフォルダのIDを取得し、GASのスクリプト内で利用します。
フォルダIDはGoogleドライブのURLに含まれている文字列で、たとえば
`https://drive.google.com/drive/folders/XXXXXXXXXX` の `XXXXXXXXXX` の部分です。

## GASスクリプトにファイルアップロード処理を追加

GASでファイルを扱うには、アップロードしたファイルを取得し、Googleドライブのフォルダに保存するコードを追加します。
以下に、新たに追加するファイルアップロードのためのスクリプトを示します。

```javascript
/**
 * ファイルアップロードを処理し、指定したフォルダに保存します。
 *
 * @param {Object} form - フォームから送信されたオブジェクト
 * @returns {string} ユーザーに表示するメッセージ
 */
function uploadFile(form) {
  try {
    // アップロード先のフォルダIDを指定
    const folderId = 'YOUR_FOLDER_ID';
    const folder = DriveApp.getFolderById(folderId);

    // フォームからファイルを取得
    const blob = form.fileInput;
    if (!blob) {
      throw new Error('ファイルが選択されていません。');
    }

    // フォルダにファイルを保存
    const file = folder.createFile(blob);
    return `ファイル "${file.getName()}" が正常にアップロードされました。`;
  } catch (error) {
    return `ファイルのアップロード中にエラーが発生しました: ${error.message}`;
  }
}
```

このスクリプトは、フォームから送信されたファイルを
Googleドライブの指定フォルダに保存します。

## HTMLフォームにファイルアップロード要素を追加

次に、HTML側にファイルアップロードのインプットフィールドを追加します。以下のようにHTMLを編集してください。

```html
<!-- ファイルアップロード -->
<div class="mb-3">
  <label for="fileInput" class="form-label">ファイルアップロード</label>
  <input type="file" class="form-control" id="fileInput" name="fileInput">
</div>
```

フォームにファイルアップロード用のインプットフィールドを追加することで、ユーザーがファイルを選択できるようになります。

## JavaScriptでファイルを送信

JavaScriptコードを編集し、フォームのデータにファイルも含めて送信できるようにしましょう。

```javascript
function submitForm() {
  const form = document.getElementById('myForm');
  const formData = new FormData(form);

  // スピナーを表示
  document.getElementById('spinnerOverlay').classList.remove('hidden');

  // フォームデータをオブジェクトに変換
  const data = {};
  formData.forEach((value, key) => {
    if(key === 'fileInput') return;
    if (data[key]) {
      if (Array.isArray(data[key])) {
        data[key].push(value);
      } else {
        data[key] = [data[key], value];
      }
    } else {
      data[key] = value;
    }
  });

  // 先にファイルを送信
  google.script.run
    .withSuccessHandler(function(response) {
      alert(response);
    })
    .withFailureHandler(function(error) {
      alert('エラーが発生しました: ' + error.message);
    })
    .uploadFile(form);

  console.log('C')

  google.script.run.withSuccessHandler(function(response) {
    alert(response);
    form.reset();

    // スピナーを非表示
    document.getElementById('spinnerOverlay').classList.add('hidden');
  }).withFailureHandler(function(error) {
    alert('エラーが発生しました: ' + error.message);

    // フォームを有効化
    Array.from(form.elements).forEach(element => {
      element.disabled = false;
    });

    // スピナーを非表示
    document.getElementById('spinnerOverlay').classList.add('hidden');
  }).submitForm(data);
}
```

ちょっと無理やり感がありますが、
これでファイルアップロードの機能がフォームに追加され、
ユーザーが選択したファイルをGoogleドライブにアップロードすることができます。

非同期に実行されるのでalertのタイミングが変になる可能性があります。
google.scrpt.runのwithSuccessHandlerの中でチェーンさせて処理を書けば
もう少しいい感じにはできると思います。

一応機能だけはこれで実現できるので、後は要件に合わせて修正してください。
スプレッドシートにはファイルの情報が書き込まれていないので
アップロード処理が終わったときに、
ファイルIDを書き込みに行くなどすれば記録が可能です。

## 最後に

今回の手順で、GASを使ったフォームにファイルアップロード機能を追加する方法を紹介しました。社内での利用や特定のユーザー向けのフォームに最適です。ぜひ試してみて、独自のフォーム作成に役立ててください。

何か問題が発生したり、わからない部分があれば、お気軽にご質問ください！


ちょっと待ってね

# 最後に
今回はGASと素のJSで簡単に作るHTMLフォームを紹介しました。
誰かの役に立てば幸いです！


# 参照
[スプレッドシートの内容をアクセスした人に応じて表示するウェブアプリ【Google Workspace（for Education）用】 #GoogleAppsScript - Qiita](https://qiita.com/takatama/items/b70ca9c73748df774ef0)