Googleフォームをカスタマイズしてさらに編集可能にした

## はじめに

[京都のテイクアウト情報が集まるサービスを作った（GAS）](https://qiita.com/hasehiro0828/items/8c3de344aade3729de06)と関連する内容です。
こちらも是非ご覧ください！

また記事で触れているサービスはこちらです。京都在住の方は是非使ってみてください👘
https://kyoto-take-out.web.app/

このサービスではGoogleフォームを使用してお店の情報を集めております。
一つの回答が一つのお店の情報としており、今までは回答を編集していただくことで、お店の情報をアップデートしていました。
しかし、フォームが上手くいかなかったという問い合わせや、画像が投稿できない[^1] [^2]といった問題がありました。

そこで、サービス上にGoogleフォームをカスタマイズして埋め込むことを考えました。
`google form カスタマイズ`などとググると色々な情報が出てくるのですが、
そのままの方法ですと編集が出来ません。（普通にやるとGoogleフォームの画面に行ってしまう）

そこで少し工夫をすることで、Googleフォームをカスタマイズし、なおかつ編集可能にしたので、
その時にやったことを書いていきます。

## 使用技術

- TypeScript
- GAS
- Googleフォーム
- Spreadsheet

## 概要

今回やったことの概要は以下のようになっています。

- Googleフォームをサービスに埋め込む
  - これについては[Googleフォームを自在にカスタマイズする](https://qiita.com/curioussafuta/items/fa077cbf820faca30598)がとてもわかりやすいのでご覧ください。
- GASで編集用リンクを生成
- GASでメール送信

まずGoogleフォームについてなのですが
`form`の`action`は`"https://docs.google.com/forms/d/e/xxx/formResponse"`となっており、これを自作のフォームに貼り付けることでフォームをカスタマイズし埋め込むことができます。

そして、フォームの回答一つ一つにはIDがついており、編集時の`form`の`action`は`"https://docs.google.com/forms/d/e/xxx/formResponse?edit2=***"`のように回答のIDが付与されています。

そこで、自作サービスのフォームにアクセスするURLにパラメータとしてフォームの回答IDを渡し、フロントで`action`に付与することで編集を可能にしています。

この時に編集前のフォームの回答は自動では取得できないので、一緒にパラメータとして渡しています。
実際には回答内容そのままではなく、店舗ID（フォームのIDではない）を渡しています。

なお、フロント側の処理については書かないのでご了承ください。

## やったこと

### GASプロジェクトの作成

まず、GASのプロジェクトを作成するのですが、この時はフォームから作成するようにしてください。
つまり

1. Googleフォームにアクセス
2. 右のメニューからスクリプトエディタを選択

とするようにしてください。

今まで知らなかったのですが、GASはプロジェクトの作成方法（紐づいている先？）によって、**トリガーに設定できる内容と受け取れる値が異なります**。

今回はフォームのトリガーが必要なので、必ずフォームから作成するようにしてください。

### コード作成

#### 全文

```typescript:main.ts
import { SpreadsheetService } from "./spreadsheetService";

const handleSubmitForm = (e: GoogleAppsScript.Events.FormsOnFormSubmit) => {
  console.log(e);
  const formAnswer = e.response;

  const spreadsheetService = new SpreadsheetService();

  const arrayOfAnswer = spreadsheetService.getDataFromFormAnswer();

  console.log(formAnswer.getTimestamp());
  // timestampが同じで店舗名が同じものを取得
  const targetAnswer = arrayOfAnswer.find((answer) => {
    if (formAnswer.getTimestamp().getTime() === answer.timestamp.getTime()) {
      const itemResponses = formAnswer.getItemResponses();
      const itemResponseOfStoreName = itemResponses.find(
        (item) => item.getItem().getTitle() === "店舗名"
      );
      const value = itemResponseOfStoreName.getResponse();
      return value === answer.storeName;
    }

    return false;
  });

  const storeID = targetAnswer ? targetAnswer.ID : undefined;
  if (!storeID) console.log("storeIDがありません");
  const formID = formAnswer.getId();

  console.log(`[storeID]: ${storeID}`);
  console.log(`[formID]: ${formID}`);

  let formAnswerText = "";
  formAnswer.getItemResponses().forEach((itemResponse) => {
    const question = itemResponse.getItem().getTitle();
    const answer = itemResponse.getResponse();
    formAnswerText += `${question} : ${answer}\n`;
  });

  let body = "この度はフォームのご回答誠にありがとうございます。\n\n";

  body += "ご回答内容は以下の通りです。\n\n";
  body += formAnswerText + "\n";

  body += "ご回答内容の編集はこちらからお願いします。\n";

  body += `https://***/form?storeID=${storeID}&formID=${formID}\n\n`;

  const options: GoogleAppsScript.Gmail.GmailAdvancedOptions = {
    name: "NAME",
    bcc: "test@gmail.com",
  };

  GmailApp.sendEmail(
    targetAnswer.email,
    "【編集リンクあり】フォームのご回答誠にありがとうございます",
    body,
    options
  );
};
```

```typescript:spreadsheetService.ts
export class SpreadsheetService {
  spreadsheetID: string;
  constructor() {
    this.spreadsheetID = PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID");
  }

  getSheetByName = (sheetName: string): GoogleAppsScript.Spreadsheet.Sheet => {
    // シート情報を取得
    const sheet = SpreadsheetApp.openById(this.spreadsheetID).getSheetByName(
      sheetName
    );
    if (!sheet) {
      console.error(`「${sheetName}」が存在しません`);
      return undefined;
    }
    return sheet;
  };

  getIndexesOfFormAnswer = (firstRow: string[]) => {
    return {
      timestamp: firstRow.indexOf("タイムスタンプ"),
      email: firstRow.indexOf("メールアドレス"),
      storeName: firstRow.indexOf("店舗名"),
    };
  };

  getDataFromFormAnswer = (): SpreadsheetModel.FormAnswer[] => {
    const sheetName = "フォームの回答 1";
    const sheet = this.getSheetByName(sheetName);

    const firstRow = this.getFirstRowFromSheet(sheet);

    const data = sheet
      .getRange(2, 1, sheet.getLastRow() - 1, firstRow.length)
      .getValues();

    const indexOf = this.getIndexesOfFormAnswer(firstRow);

    for (const key in indexOf) {
      if (indexOf[key] < 0) {
        console.log(`[${sheetName}]に[${key}]が存在しません`);
      }
    }

    const arrayOfAnswer: SpreadsheetModel.FormAnswer[] = [];
    data.forEach((row, index) => {
      arrayOfAnswer.push({
        ID: index + 1,
        timestamp: row[indexOf.timestamp],
        email: row[indexOf.email],
        storeName: row[indexOf.storeName],
      });
    });
    return arrayOfAnswer;
  };

  getFirstRowFromSheet = (sheet: GoogleAppsScript.Spreadsheet.Sheet) => {
    const indexOfLastColumn = sheet
      .getRange(1, 1)
      .getNextDataCell(SpreadsheetApp.Direction.NEXT)
      .getColumn();
    const firstRow = sheet.getRange(1, 1, 1, indexOfLastColumn).getValues()[0];

    return firstRow;
  };
}

```

#### 詳細解説

`SpreadsheetService`はスプレッドシートから情報を取ってくるもので、
`handleSubmitForm`はフォーム回答時のトリガーに設定しており、`e`にはフォームの回答内容などが入っています。（詳しくは[Google Forms events](https://developers.google.com/apps-script/guides/triggers/events#google_forms_events)を参照）

```typescript
const spreadsheetService = new SpreadsheetService();

const arrayOfAnswer = spreadsheetService.getDataFromFormAnswer();

console.log(formAnswer.getTimestamp());
// timestampが同じで店舗名が同じものを取得
const targetAnswer = arrayOfAnswer.find((answer) => {
  if (formAnswer.getTimestamp().getTime() === answer.timestamp.getTime()) {
    const itemResponses = formAnswer.getItemResponses();
    const itemResponseOfStoreName = itemResponses.find(
      (item) => item.getItem().getTitle() === "店舗名"
    );
    const value = itemResponseOfStoreName.getResponse();
    return value === answer.storeName;
  }

  return false;
});
```

スプレッドシートより取得したものからフォームの回答と`timestamp`が同じで、`店舗名`も同じものを取得しています。
こうしている理由は、スプレッドシートのフォームの回答が何番目かを店舗の`ID`としており、編集用リンクにはこの`ID`を渡したいからです。
パラメータに直接回答内容を入れれば楽なのですが、そうした場合は前回の編集リンクから編集しないとおかしなことになってしまうので、店舗のIDにすることで、実際にサービス上で編集する際に最新のデータを取得するようにしています。

`timestamp`だけだと同じタイミングの回答があるとおかしなことになるので、`店舗名`も使用しています（これでも理論的には完全ではありませんが、実用上は恐らく大丈夫だろうという感じ）

また、フォームの回答時のトリガーは回答が反映されてから発火するっぽいので、新しい回答時にも回答の内容がスプレッドシートからも取得できます。（厳密には同期的ではないかもしれないですが未確認。念の為スリープとか最初にしたほうがいいかも）

### トリガー設定

あとはGASに`clasp`などでpushし、GASエディタから以下のようにトリガーを設定すれば完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209015/63c4eba0-7cc2-e8bb-c850-7cf381f11430.png)

### フロントでの処理
以上で編集用リンクの作成とメールの送信ができるようになりました。
あとはフロントエンドでURLのパラメータを処理し、データを取得し、formに初期値を入れれば編集フォームの完成です。

## おわりに
今回はGoogleフォームをカスタマイズし、さらに編集もできるようにしました。
案外使えそうだなと思ったので、参考にしてみてください。
お役に立てましたら幸いです。

ありがとうございました🙇‍♂️


[^1]: GASのHTML serviceを使いアップローダーを作ってみたりしたがイマイチだった。
[^2]: Googleアカウントを使うようにすれば可能なのですが、Googleアカウントを持っていないユーザも多いと判断し、導入しませんでした。