GASでフォームのプルダウン更新


# 導入背景
某オンラインFPSゲームの大会を運営しており、参加チームのエントリーをgoogleフォームで管理していました。
また、大会で使用する5つのマップに点在するエリアの中から、各チームが降下するエリア(**ランドマーク**と呼びます)を選定して頂く必要があり、このランドマークの集計に関してもgoogleフォームを運用していました。

このランドマークについては1チーム1つしか選択できず、他のチームと競合することが許されません。
そこで、**前の回答者によって既に回答された選択肢を、次の回答者が選べないようにする**
といった機能が必要になったので、GASで実装しました。

## フォームの情報
フォームにはエントリーチーム名を選択するセクション、画像のみを表示したセクションと、プルダウンリストから回答を選択するセクションが混在しています。
このうち今回選択肢の更新を行う対象のセクションはプルダウンリストから回答を選択するセクションであり、5つ存在します。(下図参照）

| 項番 | セクション内容 |
|:-:|:-:|
| 1 | チーム名を選択 |
| 2 | 1番目のマップ画像 |
| 3 | ランドマーク選択 |
| 4 | 2番目のマップ画像 |
| 5 | ランドマーク選択 |
| 6 | 3番目のマップ画像 |
| 7 | ランドマーク選択 |
| 8 | 4番目のマップ画像 |
| 9 | ランドマーク選択 |
| 10 | 5番目のマップ画像 |
| 11 | ランドマーク選択 |



例えば項番3の質問は、以下のようにフォーム上で設定されています。
### ▼フォーム上の質問リスト
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558257/815d847e-c7e0-9ae7-dffa-446afbeb2810.png)
### ▼回答者から見たプルダウンリスト
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558257/c07d3383-41d3-c455-ed05-8acc5fcbc69d.png)

1人目の回答者が、1.試練を選択して回答したとき
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558257/dd2ca8ef-1e49-dfcf-97bc-e66824b159ff.png)

2人目の回答者が同プルダウンを開くと、質問リスト上から1.試練が削除され
選択肢が繰り上がって表示されるのが期待値です。
### ▼フォーム上の質問リスト(更新後)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558257/b51d7319-1273-23d7-7173-86b3532414dd.png)
### ▼回答者から見たプルダウンリスト(更新後)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558257/0da9304d-ba94-fbd7-711b-082494a4c533.png)

## 実装したスクリプトの解説

▼googleフォームをGASで操作する為のインタフェース
```gas:フォームのIDを使用してフォームを開く:
var form = FormApp.openById(formId);
```
:::note warn
※formIdは、AppsScriptのスクリプト プロパティなどに別途記述して利用するのが良いでしょう。
べた書きは推奨しません。
:::

以下、mainメソッドです。サブルーチンの解説は後述していきます。
### ▼mainメソッド
```gas:main
function main() {
  // 最初の質問が格納されたインデックスを宣言
  let questionIndex = 2;

  // 3, 5, 7, 9, 11番目の質問（インデックス=2,4,6,8,10）リストを取得
  // 2, 4, 6, 8, 10番目の質問は画像セクション（インデックス=1,3,5,7,9）なのでSKIPしたい。
  // その為、イテレーションは2ずつ行う
  for (questionIndex; questionIndex <= 10; questionIndex+=2) {

    // 質問リストを取得
    var q_list = getListItems(questionIndex);
    // 回答を取得
    var r_list = getFormResponses(questionIndex);

    // q_listとr_listを比較して重複を取り除く
    var uniqueItems = differenceSets(q_list, r_list);

    // プルダウンリストの選択肢を更新
    updateFormDropdown(questionIndex, uniqueItems);
  }
}
```
大雑把に言えば、フォームの質問一覧と、それに紐づく回答一覧を突合し
重複している値を質問から取り除く、という処理を行っています。

---
### ▼質問リスト取得メソッド
```gas:getListItems
function getListItems(questionIndex) {

  // フォームから質問を取得
  var question = form.getItems()[questionIndex].asListItem();
  
  // 質問のリスト内項目を取得
  var listItems = question.getChoices().map(function (choice) {
    return choice.getValue();
  });

  return listItems;
}
```
`getItems()`は、フォーム内のすべてのアイテムの配列を返します。
[questionIndex]は、その配列から特定のインデックス位置のアイテムを取得するための構文です。この場合、questionIndexには取得したいアイテムのインデックス番号が格納されます。

https://developers.google.com/apps-script/reference/forms/form?hl=ja#getItems()

そして、`.asListItem()`は、取得したアイテムをリストアイテム（ドロップダウン、ラジオボタンなど）として扱うためのメソッドです。これにより、リストアイテムとしての特定の操作（例：選択肢の取得や設定）が可能になります。

https://developers.google.com/apps-script/reference/forms/item?hl=ja#asListItem()

`getChoices()`は、先ほど取得した質問の選択肢を配列として返します。
この配列に対して、`map`メソッドを用いて配列内の値を取得していきます。
function (choice) { ...の部分では、choiceという匿名関数を宣言し
配列の各要素に対して、choiceというローカル変数としてアクセスします。
そして、getValue()によって値を取得しています。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map


---
### ▼回答リスト取得メソッド
```gas:getFormResponses
function getFormResponses(questionIndex) {
  // フォームのすべての回答を取得
  var formResponses = form.getResponses();
  
  // プルダウンの回答のみを格納する配列を作成
  var responses = [];
  
  // 各回答の内容を取得してresponsesに追加
  for (var i = 0; i < formResponses.length; i++) {
    var response = formResponses[i];
    var itemResponses = response.getItemResponses();
    
    // questionIndexに対する回答を取得
    var itemResponse = itemResponses[questionIndex/2];
    
    // itemResponseが存在する場合のみ回答を追加
    if(itemResponse) {
        responses.push(itemResponse.getResponse());
    }
  }
  
  return responses;
}
```
この部分の実装で気を付けたのは、回答の格納された配列は質問のインデックスと異なり
回答の個数にインデックスが割り当てられている事です。
今回を例にすると、ユーザに回答してもらう回答数の期待値は6です。
すなわち、回答の大きさは6となり、0~5のインデックスにそれぞれの回答が格納されているのです。

また、questionIndex/2としている理由ですが
各質問は、インデックス=2,4,6,8,10
それに対する回答は、インデックス=1,2,3,4,5に格納されている為
/2とすることで各質問に対する回答が得られます。

---
### ▼リスト比較メソッド
```gas:differenceSets
function differenceSets(setA, setB) {
  var difference = setA.filter(item => !setB.includes(item));
  return difference;
}
```
`filter` メソッドは、与えられた関数のテストをパスするすべての要素、を持つ新しい配列を生成します。与えられた関数を配列の各要素に対して実行し、その関数が true を返す要素だけを新しい配列に含めます。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter

`includes` メソッドは、特定の要素が配列に含まれているかどうかを判定します。含まれている場合は true を、そうでない場合は false を返します。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/includes

---
### ▼フォーム更新メソッド
```gas:updateFormDropdown
function updateFormDropdown(questionIndex, choices) {
  var item;
  try {
    item = form.getItems()[questionIndex].asListItem();
  } catch (e) {
    Logger.log("Error at index: " + questionIndex + " with error message: " + e.message);
    return;
  }
  var choicesToSet = [];
  //選択肢の更新処理
  for (var i = 0; i < choices.length; i++) {
    choicesToSet.push(item.createChoice(choices[i]));
  }
  item.setChoices(choicesToSet);
}
```
関数は2つの引数を受け取ります。
questionIndex：更新したい質問のインデックス
choices：新しいドロップダウンリストの配列(differenceSetsが返した値。すなわち重複を取り除いた配列)

例外処理では、取得した質問の形式がリスト以外の場合、例外をキャッチして処理を終了します。
選択肢の更新処理は、`createChoice`メソッドを用いて、choices[i]の値から新しく選択肢を作成します。
choicesToSet変数に新しい選択肢の配列がpushされていきます。

https://developers.google.com/apps-script/reference/forms/multiple-choice-item?hl=ja#createChoice(String)

最後に、`setChoices`メソッドにより、ドロップダウンリストの選択肢をchoicesToSetの内容で更新します。

https://developers.google.com/apps-script/reference/forms/multiple-choice-item?hl=ja#setChoices(Choice)

---
### ▼リストの戻し用関数
重複除外により消えてしまったリストの戻しは、以下の関数をAppsScriptから実行することで復元されます。
```gas:fix_list
function fix_list(){

  //質問内容更新(テスト時使用)
  let allWords = [
    //worldsedge_s18時点
    [
    "試練", "スカイフック", "カウントダウン", "溶岩溝", "ランドスライド",
    "ミラージュ・ア・トロワ", "中継地点", "火力発電所", "調査キャンプ", "クリマタイザー",
    "エピセンター", "モニュメント", "フラグメント", "展望", "間欠泉", "ハーベスター", 
    "ザ・ツリー", "ラバサイフォン", "ビッグ・モード", "スタックス", "発射場", "ザ・ドーム"
   ],
   //kingscanyons_s18時点
   [
    "スポテッドレイク", "ザ・ピット", "ランオフ", "バンカー", "航空基地",
    "ガントレット", "レリック", "クラッシュサイト", "砲台", "収容所",
    "マーケット", "盆地", "ザ・リグ", "キャパシター", "研究所",
    "沼沢", "ザ・ケージ", "ハイドロダム", "リパルサー", "マップルーム",
    "コースティックの研究所"
   ],
   //olympus_s18時点
   [
    "ドック", "母艦", "ファイトナイト", "オアシス", "タービン",
    "エステート", "ターミナル", "エリジウム", "水耕施設", "フェーズドライバー",
    "送電網", "リフト", "エネルギー貯蔵庫", "ハモンド研究所", "ガーデン",
    "クリニック", "グロータワー", "ソーラーアレイ", "軌道砲", "イカロス",
    "盆栽プラザ"
   ],
   //stormpoint_s18時点
   [
    "ノースパッド", "チェックポイント", "ダウンビースト", "ザ・ミル", "セノーテ洞窟",
    "バロメーター", "ザ・ウォール", "コマンドセンター", "カスケードフォール", "アンテナ",
    "座礁区域", "高地", "避雷針", "サンダーウォッチ", "ストームキャッチャー",
    "発射台", "ゲイルステーション", "養殖場"
   ],
   //brokenmoon_s18時点
   [
    "ブレイカーワーフ", "ドライガルチ", "生産工場", "鋳造所", "ザ・コア",
    "N.プロムナード", "S.プロムナード", "カルティベーション", "アルファベース", "スタシスアレイ",
    "テラフォーマー", "アトモステーション", "バックアップアトモ",
    "エターナルガーデン", "ザ・ディバイド", "バイオノミクス"
   ]
  ]

  for (let i = 0; i < allWords.length; i++) {
    updateFormDropdown(2*i+2, allWords[i]);
  }
}
```
allwordsには、各質問文のプルダウンリストを文字列として設定しておきます。
for文で、変数iを開始地点とし、allWordsの大きさの範囲で、ループ処理させます。
ループ処理内では、updateFormDropdownを呼び出します。
3, 5, 7, 9, 11番目の質問（配列上は2,4,6,8,10）に対して処理をさせたいので
updateFormDropdownの引数は2*i+2, allWords[i]としました。

---
### ▼トリガー設定
最後に、このスクリプトのトリガーを設定します。
トリガーは、フォームの回答送信時とします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558257/71625ede-766c-c900-5551-c96746365e88.png)



# 参考リファレンス
https://developer.mozilla.org/ja/
https://developers.google.com/apps-script/reference?hl=ja

実装にはchatgptも活用しました。
こちらの要件を的確に伝えるためのプロンプトを生成することは、要件を文章で具体化する訓練としても役立ちました。