【React】絶対に真似してはいけないGoogle Formのお問合せフォーム化【Google Forms】

61377 ワード

はじめに

皆様「おまえの次のセリフは『お久しぶりです』という!」
投稿者「『お久しぶりです』…はッ!」
…はぁ…まるで成長していない…。

さて、今回は「絶対に真似してはいけないGoogle Formのお問合せフォーム化」と銘打ちまして、Google FormをReact製Webページの問い合わせフォームにする姑息な実装をまとめたいと思います。
一応zennその他にも似たような投稿はあったんですが、

  • Reactで運用する記事がない(←色々ライブラリあるし、きっといらない)
  • 投稿後の画面遷移が実現できていない
  • (さも悪くないの方法かのように書かれていたのが怖い)
  • (↑浅瀬の投稿者がこんなこと言っちゃって燃える方がもっと怖い)
  • (↑「なんて言いながら記事にしてる!」って燃える方がさらに怖い)
  • いきなり動かなくことを思うと夜しか眠れないので動作確認用のサンプルがほしい

などの理由から、一応手元でも記事化することにしました。

んで、本題に入る前に、一応ね、再掲です。
明らかに想定とは異なる活用方法なんで、一般的な要件では採用すべきではありません。
…え、あんまよくないよね…?
教えて!偉い人!

事前情報

Google Formsの誘惑

投稿者:「絶対に真似してはいけない」「あんまよくない」
皆様:「さてはアンチだなオメー」

えっとですね、実はGoogle Forms、実は割と魅力ありんす。
具体的には以下ですかね?

  • (実質)登録なしで(多分)無料で(きっと)自由に使える
  • (フォーム使うだけなら)猿でも使える
  • 採用するだけでサーバーレス()
  • GASで自動返信メールとか、Slackとかに通知なんか出せちゃったり

…こうやってみると、意外と一般的なフォームサービスと変わらないレベルの運用ができるんですよね。
「なんか焦げ臭くね?」と感じている方もいらっしゃるかもしれませんが、どうでしょう。
外でお米を炊くときのおこげみたいな、甘い匂いじゃありませんか?

誘惑に負けちゃった要件

して、投稿者にこんな不埒なことを考えさせた要件はこんな感じ。

  • サイトの規模がだい〜ぶ小規模
  • サーバーレスにできる、お問合せフォームさえなければ
  • サーバー側がまだ完成してないけど、できればフォーム入力を受け付けたい。
  • (現状の管理をGoogle Driveでやってるから、そこにデータを入れたい。)

…どうでしょう、ありません?
え?ない??あっても使わない???
…あ、いてて!石はマズいですよ!せめて卵ぐらいにs…グシャ
ウゥ…

さて、ちょっと頭が黄色くベタついでいますが、気を取り直して本題に入りましょう。

とりあえず、作ってみよ

今回の実装のコードはこちらのすかすかリポジトリにまとめております。
人様に解説できたようなもんじゃありませんが、反面教師としてならいっぱしの活躍が期待できますよ!

兎にも角にも、まずは作ってみましょ。
文句はそれから、ということで。

下準備

今回はテストってことで、create-react-app でさくっと作ります。
言語はTypeScript、UIライブラリにはMUI(旧 Material-UI)を採用してます。
方向性は、「フォームへの入力をFormDataにまとめて、Axiosでソイヤッ」って感じ。
今回はどうせテストなんで、状態管理はデフォルトで。
たまには、おとなしくバケツリレーでもどうすか。
まぁ、運動不足だし、多少はね。
では釈迦に説法だと思うので、この辺ができたところから、お話を始めましょっか。

Goolge Formをつくる

zennで読まれている皆さんは、ちょいちょい使っていらっしゃると思います。
話すこと、なし!!

…いや、そんなことなかったわ、質問項目は明示しなきゃ。
今回使うフォームはこんな感じ。

今回使うフォーム

扱う項目は

  • 名前(name):短文形式
  • 性別(gender):ラジオボタン
  • 言語(language):セレクトボックス
  • メッセージ(message):長文形式

の4項目ですね。

んでは、ゆるゆる実装していきましょう。
データをgoogleのフォームにシュゥゥゥーッ!!
超!エキサイティン!!

必要な項目を引っ張り出す

FormDataの送付先

まずは相手のゴールを用意しましょう。
google formの「送信ボタン」を押した後、リンクのURL先に飛ぶと、いつものフォームが出るじゃないですか。
あそこで「検証」して探します。
FormDataの送付先はここ↓
FormDataの送付先
form要素についてるactionからURLを取ってきます。
最後がformResponseで終わってるやつ。
とりあえず、コレをメモっときましょ。

各要素との紐づけ

ゴールには、シュゥゥゥーッ!!するためのボールが必要ですよね。
で、そのボールは送信する入力データ(value?)とヒモ付のためのkey(?)で作ります。
keyの元になるのはここ↓
formDataのKeyの素1
フォームの要素をマウスオーバーすると出てくるはずです。
どや↓
formDataのKeyの素2
ここのdata-paramsは、こんな感じ↓

%.@.[1409780750,"name",null,0,[[1064710882,[],false,[],[],null,null,null,null,null,[null,[]]]],null,null,null,[],null,null,[null,"name"]],"i1","i2","i3",false]

なんですけど、必要になる部分は5つ目の要素にあたるぐちゃぐちゃ配列の最初の要素。
1064710882が今回のそれに当たります。
ほいで、フォームの送信にはentry.の枕詞が必要です。
//おまじない的な。
entry.1064710882的な。

今回は一連のデータをformKeys.tsに入れて、外部から取り出すことにしましょう。

で、出来上がったformKeys.tsがこちら。

const FormKeys = {
  formUrl: "https://docs.google.com/forms/u/3/d/e/1FAIpQLScPFoCDYJyRg-zPiRFY3httbaPisHSlSJhau4ml_75LfFoWKQ/formResponse",
  name: "entry.1064710882",
  gender: "entry.738150545",
  language: "entry.325866619",
  message: "entry.1751766225"
};

export default FormKeys;

う〜ん、.jsonとかでも良かったな、こりゃ。
気を取り直して、次は送信するフォームデータを準備しましょ。

フォームの作成

ということで、3分フォームクッキング〜!

全☆略!

そして、完成したものがこちらになります。

// Form.tsx
// いろいろと割愛
const Form: React.VFC = () => {
  const [loading, setLoading] = useState(false);
  const [name, setName] = useState('');
  const [gender, setGender] = useState('');
  const [message, setMessage] = useState('');
  const [language, setLanguage] = useState({
    JavaScript: false,
    TypeScript: false,
    Ruby: false,
    Python: false,
    other: false,
  });

  const navigate = useNavigate();

  const handleClick = () => {
    setLoading(true)
    const submitParams = new FormData();

    submitParams.append(FormKeys.name, name);
    submitParams.append(FormKeys.gender, gender);
    submitParams.append(FormKeys.message, message);
    Object.entries(language).forEach(([key, value]) => {
      value && submitParams.append(FormKeys.language, key);
    });

    Axios.post(FormKeys.formUrl, submitParams).then(() => {
      setLoading(false);
      navigate("/complete");
    }).catch(() => {
      setLoading(false);
      console.log('エラーです');
    })
  };

  return(
    <div>
      <Typography variant="h5" align="center">真似しちゃいけないフォーム</Typography>
      <form>
        <NameField
          name={name}
          setName={setName}
        />
        <GenderField
          gender={gender}
          setGender={setGender}
        />
        <LanguageField
          language={language}
          setLanguage={setLanguage}
        />
        <MessageField
          message={message}
          setMessage={setMessage}
        />
        <Button fullWidth variant="contained" onClick={handleClick}>
          フォームを送信
        </Button>
      </form>
      <Backdrop
        open={loading}
      >
        <CircularProgress color="inherit" />
      </Backdrop>
    </div>
  );
};

export default Form;

横着すな! バシッ

い、いやぁ、違うんですよ。
フォーム自体は普通にMUIでつくるのと変わらんとです。
例えばお名前フォーム。

// NameField.tsx
const NameField: React.VFC<NameFieldProps> = (props) => {
  const {name, setName} = props;

  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
    setName(event.target.value);
  };

  return (
    <FormControl>
      <TextField
        type="text"
        value={name}
        label="名前"
        onChange={(event) => handleChange(event)}
        variant="standard"
      />
    </FormControl>
  );
};

export default NameField;

ほらね、まんま公式w
ってことで、細かい作り方はMUIの公式ドキュメントへどうぞ!
私のほうでは、送信部分の説明だけ簡単にさせてもらいます。

今回の根幹

…ご期待に添えず恐縮ですが、ラップはあんまり得意では…
え?隠者には期待してない?
デスヨネー、ハハハ。

んじゃ、送信部分を担当する、こちらのメソッドをご覧ください。

const handleClick = () => {
  setLoading(true)
  const submitParams = new FormData();

  submitParams.append(FormKeys.name, name);
  submitParams.append(FormKeys.gender, gender);
  submitParams.append(FormKeys.message, message);
  Object.entries(language).forEach(([key, value]) => {
    value && submitParams.append(FormKeys.language, key);
  });

  Axios.post(FormKeys.formUrl, submitParams).then(() => {
    setLoading(false);
    navigate("/complete");
  }).catch(() => {
    setLoading(false);
    console.log('エラーです');
  })
};

やってること自体は割と単純です。

  1. FormDataオブジェクトを作る
  2. さっき作ったFormKeysからkeyを持ってくる
  3. keyに対応するようにstateとして保存してるvalueを持ってくる。
  4. postでポイ〜

チェックボックスの扱いだけがちょっと特殊ですかね?
stateをbooleanでとって、valueがtrueのkeyだけを、stringとしてFormDataのvalueに入れます。
…うん、日本語でおk

とにかく、大事なのは 「一つのチェックされた値につき、一つのペアを用意する」 ってことですね。
要件に応じてエラーハンドリングとかしてもろて、後は送信ポチー

…ところがどっこい、そうはいかないんですね。

真似しちゃいけないわけ

本当の問題

実のところ、およそ問題はここからっす。
先程のコード、このまま送信するとこんなエラーが出ます[1]

CORSエラー

なになに、「Access-Control-Allow-Originがねーよ、乙w はいブロックぅ〜ww」
…イラッ! ピキピキッ!

はい、バカ言ってんじゃね〜よw
そう、google formにはCORSについて制限がかかってるんです
またまた孔子に論語案件かとは思いますが、不学の徒たる私は「はぁ?なにそれぇ?」となったんで調べました。
参考:なんとなく CORS がわかる...はもう終わりにする。
よし、なんとなくわかったな!

さぁて、この制限をどうするか…。
コレが私の答えだ!

…ブチ抜きます。

ズダキューーン!!
…ドチャッ…
投稿者の頭を、警官隊の銃弾がぶち抜いた瞬間である。

CORSをぶち抜く

…フム、これが「異世界転生」、ですか。
おや、こいつぁもしや、チート装備ってヤツ!?

ってなわけで、こちらのリポジトリをご覧ください。
この聖剣の名称はcors-anywhere
README曰く、

CORS Anywhere is a NodeJS proxy which adds CORS headers to the proxied request.
貴様のリクエストにCORSヘッダをくれてやろう…!

…大丈夫?呪いの装備じゃないよね?
とにかく、cors-anywhereでgoogleのCORSの鍵を切り裂きましょう!
https://cors-anywhere.herokuapp.com/
↑こいつがLive examplesだってよ。

ただし、こいつは使えません。
詳しくはこちらのissue
つまり、こういうこと。
てめぇらが後先考えずにつかうから、利用制限かけるわ!プンプン

…す、すいませんでしたぁ〜 スザァァァ…。←テキトーに使ってた人。
まぁ見た感じ、ちょっとしたテストとして活用する分には問題なさそうです。
常用するな! ってことでしょう。

んでは、今回のように常用したいケースはどうするか。
issueにも書いてありますが、セルフホスティングしましょう。
私の場合はherokuを使いました。
…方法は…割愛でいいかなw

んで、ホスティングしたアドレスをformKeysにこう…

const FormKeys = {
  formUrl: "https://docs.google.com/forms/u/3/d/e/1FAIpQLScPFoCDYJyRg-zPiRFY3httbaPisHSlSJhau4ml_75LfFoWKQ/formResponse",
  name: "entry.1064710882",
  gender: "entry.738150545",
  language: "entry.325866619",
  message: "entry.1751766225",
  corsAnywhere: "https://[herokuのアドレス].herokuapp.com/"
};

export default FormKeys;

ペッと貼って、Axiosのpost部分にも…

const handleClick = () => {
  setLoading(true)
  const submitParams = new FormData();

  submitParams.append(FormKeys.name, name);
  submitParams.append(FormKeys.gender, gender);
  submitParams.append(FormKeys.message, message);
  Object.entries(language).forEach(([key, value]) => {
    value && submitParams.append(FormKeys.language, key);
  });

  // これ↓
  Axios.post(FormKeys.corsAnywhere + FormKeys.formUrl, submitParams).then(() => {
    setLoading(false);
    navigate("/complete");
  }).catch(() => {
    setLoading(false);
    console.log('エラーです');
  })
};

こんな感じで、頭にcors-anywhere のURLを貼っつけます。
コレでフォームを送信すると…
送信完了1
送信完了2
送信完了3
やったぁ〜(棒)

「…まさか、この程度のためにために禁呪を使ったわけでもあるまい…」
「ヒィ~ ま、魔王様、剣をお収めください…!ギャーーーー」

おきのどくですが
ぼうけんのしょは
きえてしまいました。

フォーム送信後にできること

い、いててて…ドロッ。
えっと、ここからはフォーム送信後にできるアレやコレやをご紹介します。
ここから先、使っていくのはGoogle Apps Script (GAS)。
まずは簡単な使い方とか含め、サラッと見ていきます。

GASの下準備

手順1
まずはこっから、スクリプトエディタをクリック!
すると…
手順1.1

は?

…いやこれね、(Chromeで編集する場合は)フォームを作ったアカウントのプロファイルを使わないと開かないんですよ。
これはどうなんだ…。

気を取り直して、同じプロファイルで開いた先はこんな感じ。
手順2
項目の詳しい説明はデキる方の記事に任せるとして、今回はこの「名状しがたいjsのようななにか」を編集していきます。

データの整形

実のところ、ここが一番面倒かも。
あとgasについてですが、私は完全に理解したと行っても過言です。
2丁拳銃の乱射とか2刀流で斬りかかるのとかはNG。

さて、まずはフォームのデータを扱いやすいように整形しておきましょう。
コードはこんな感じ。

function SendMail(e) {
  FormApp.getActiveForm();
  // 投稿内容を代入
  let itemResponses = e.response.getItemResponses();

  // 投稿内容を整形
  let itemResponsesFormatted = new Object();
  for(let i=0; i<itemResponses.length; i++){
    let itemResponse = itemResponses[i];
    let title = itemResponse.getItem().getTitle();
    let content = itemResponse.getResponse();
    itemResponsesFormatted = Object.assign(itemResponsesFormatted, {[title]: content})
  }
}

詳しい説明は省きますが、やってることはこんな感じかな。

  1. フォームの枠(?)を生成
  2. 投稿されたデータを代入
  3. Object型に変更

ここで整形しておくことで、後の整形コストが無料!
↑開発ってこんなのばっかな気がする。
ちなみに、フォームの質問名を英語1単語にしてたのは、ここのgetTitle で取得するデータをそのままObject内のkeyとして利用するため
gasを使わない場合は質問名とか日本語にしても大丈夫だと思います。

これで、通知周りの下処理は完了。
これから じっくり煮込んで さくっと通知周りを作りましょう。

問い合わせ通知(メール編)

さくっと、と言ったな。
…あれは大マジだ。

function EmailNotice(itemResponsesFormatted) {
  let subject = "起きなさい!問い合わせがあったわよ!";
  let body = "";
  body += "ふ〜ん、あなたなんかにも問い合わせなんてあるのね。\n"
  body += "私が伝えてあげたんだから、感謝して読みなさい!。\n\n"
  body += "名前:" + itemResponsesFormatted.name + "さん\n"
  body += "性別:" + itemResponsesFormatted.gender + "\n"
  body += "言語:" + itemResponsesFormatted.language + "\n"
  body += "メッセージ:" + itemResponsesFormatted.message + "\n\n"
  body += "は!?なに!?\n"
  body += "ま、マジになって感謝されても…ぜんぜんうれしくないんだからね!!"

  GmailApp.sendEmail('[あなたのメアド]', subject, body);
}

ふぅ、文面は要検討だな!
さっき整形したおかげで、処理はだいぶ簡単。
subjectで件名を指定、bodyに文面をいれつつ、フォームのデータを追加、あとはメアドを指定して、セイヤッ。
gasでメールを配信する時はGmailApp.sendEmail ってのを使うといいんだって。

また、これと同じ要領で
・メールアドレスを取得
sendEmailのメアド部分に取得したメアドを代入
・(状況に応じて送り主を変更したり?[2]
ってな変更を加えると、問い合わせありがとうメールも送れるようになったり。

ほいで、後はさっきのコードの後ろに…ピト

function SendMail(e) {
    //...中略...
    let content = itemResponse.getResponse();
    itemResponsesFormatted = Object.assign(itemResponsesFormatted, {[title]: content})
    
    EmailNotice(itemResponsesFormatted)
  }
}

これだけ。

…べ、便利だなんて思ってないんだからね!(大嘘)

問い合わせ通知(Slack編)

「Slackへの通知もやってみたいでしょ〜?」
「せーのっ!」
スッコココッ!
「あぁ〜!Slackの音ォ〜!!どうなってるんですかこれぇ〜!?」

function SlackNotice(itemResponsesFormatted) {
  let body = "";
  body += "【Slackから通知】初回限定!お一人様一本限り!\n"
  body += "ご注文内容は以下のとおりです。\n\n"
  body += "名前:" + itemResponsesFormatted.name + "さん\n"
  body += "性別:" + itemResponsesFormatted.gender + "\n"
  body += "言語:" + itemResponsesFormatted.language + "\n"
  body += "メッセージ:" + itemResponsesFormatted.message + "\n\n"
  body += "===============================\n"
  body += "Slackからの通知、受け取り放題!\n"
  body += "詳しいお問い合わせは、0120-***-***\n"  
  body += "==============================="
  
  let slackWebHookUrl = "https://hooks.slack.com/services/[取得できる固有URL]"

  //本文をJSON化
  let payload = {
    "text": body
  }

  //投稿用の再整形(JSON形式)
  let options = {
    "method":"POST",
    "headers":{"Content-Type":"application/json; charset=UTF-8"},
    "payload": JSON.stringify(payload),
    "muteHttpExceptions":true
  }
  
  UrlFetchApp.fetch(slackWebHookUrl, options);
}
function SendMail(e) {
    //...中略...
    itemResponsesFormatted = Object.assign(itemResponsesFormatted, {[title]: content})
    
    EmailNotice(itemResponsesFormatted)
    SlackNotice(itemResponsesFormatted)
  }
}

「このコードをgasに入れるだけで、Slackに通知が来てるのがわかるでしょ〜?」

…おい、Web Hookはどうしたよ…?
はい…ちゃんとご紹介します シュン

え〜、ここで出てきた新たな要素、Incoming Webhookについても見ておきましょう。
Incoming Webhook01
こいつはSlackのアプリの一つで、チャンネルにbotを追加できるんだって。
ここでは、フォームに回答があり次第、回答の内容をチャンネルに投げる感じで使います。
この辺も情報多そうなんで、詳しくは割愛で。

てか、手順通りに進めていくだけ + 使い方とかもわかりやすい と思うんで、気にすることもないでしょう。
URLもポチポチしてけば生成してもらえるし、データもイイ感じにjsonに整形すればダイジョブなはず!
せや、この辺も応用すれば、bot系のやつにはいくらでも使えそうっすね。

やっと動かせるよ…

ここまで来たらあと僅かです。
Apps Scriptのページから、
トリガー
みたいな感じでトリガーを追加。
これでフォームの送信時に、さっきのコードが実行されるように。

んでは、よろしくおねがいしまああああああああす!!!
メール通知
メール通知
Slack通知
Slack通知

へぇ、できますた。
てな感じで、今回つくる機能はここまでぇ〜。

まとめ

といった具合で、今回は「Google Formのお問合せフォーム化」などという、記事だか薪だかわからないナニカを錬成してしまいました。
だいぶ長かったと思うんで、要点だけ。

  • Google Form + cors anywhere + gas で、フォーム系の機能は意外とできてまう。
  • が、corsをぶち抜く実装になるんで、流石によろしくないんでは?

はぁ〜、後は等価交換の業火で焼かれないことを祈るばかりだ。
もし他に「安い!」「早い!」「うまい!」な実装方法を隠し持つ、画面の前のあなた!
ぜひこの辺についてご教授ください!

最後に一応、改めて。
やっぱ、普通のアプリとかでは採用しないほうがいいと思うんすよね…。
ここまで書いても、結局正解がわからないという。
…Google先生がAPIを公開してくれれば、全部解決するんだけどなぁ! チラッチラッ

脚注
  1. 実はこの状態でもデータの送信だけはできてるんですよね。この辺がめんどっちいところで…。
    一応、エラーの内容次第でハンドリング変える、とかの対処も可能かと思いますが、流石にそこまでは…と思いました、まる ↩︎

  2. この辺は難しくないんで、こんなのとか見ればいいんじゃないかな。 ↩︎