Salesforce B2C Commerce の SFRAでフォームを使ってお問い合わせフォーム実装してみる

※ これから記載する事項は、私が所属する会社とは一切関係のない事柄です。


SFRAにはフォームを簡単に実装できる仕組みがあります。その仕組みを利用してお問い合わせフォームを作成してみたいと思います。フォーム作成についての詳細は[ヘルプ](https://documentation.b2c.commercecloud.salesforce.com/DOC1/topic/com.demandware.dochelp/content/b2c_commerce/topics/sfra/b2c_create_form.html?resultof=%22%66%6f%72%6d%22%20)をご参照ください。



## 実装内容

* メールアドレス、問い合わせ種別（配送、返金・返品、その他）、問い合わせ内容（自由入力）を入力できるフォーム
* ログイン中ならアカウントに紐づくメールアドレスを利用するので、メールアドレスの入力は省く
* フォームのバリデーション（メールのフォーマット、人力必須。問い合わせ内容の入力必須。）はサーバー同期で行う
* 確認画面はなしで、入力に問題がなければ完了画面に直接遷移
* 送信成功した内容はカスタムオブジェクトに保存
* CSRF対策

![image (32).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/5f0e9240-9deb-4afe-a3dd-fefb2faef20c.png)




## 実装

### カスタムオブジェクトの作成

Business Managerの **管理 > サイトの開発 > カスタムオブジェクトタイプ** に遷移して新しいカスタムオブジェクトを作成します。

* 今回は、「CustomInquiry」というカスタムオブジェクトを作成します
* 主キー属性にはIDを設定します
* 属性にはemail（Eメール）, message（お問い合わせ内容）, type（お問い合わせ種別）を設定します
* typeの選択肢の属性には SHIPPING(配送)、RETURN_REFUND(返品・返金)、OTHER(その他)を設定します
* formTestという属性グループを作成して３つの属性を追加します。（これをしなければ、データベース登録できません）


![image (33).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/dfad5eaf-dbc2-f85d-4de0-4c7d4f4ce1b7.png)

![image (34).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/ce0a7a01-6ea9-5e2b-4577-2c570f2074fe.png)






### カートリッジの実装

今回は「app_form_test」というカートリッジを作って、その中に下記のソースコードを作成しました。
※ カートリッジの作成方法やアップロード方法については割愛します。詳しくは[Trailhead](https://trailhead.salesforce.com/ja/content/learn/modules/b2c-cartridges)、[Infocenter](https://documentation.b2c.commercecloud.salesforce.com/DOC1/topic/com.demandware.dochelp/content/b2c_commerce/topics/site_development/b2c_cartridges.html?resultof=%22%63%61%72%74%72%69%64%67%65%22%20)、[以前紹介したQIita記事](https://qiita.com/syunyama/items/21c71ac9c49b332e126d#1-%E3%82%AB%E3%83%BC%E3%83%88%E3%83%AA%E3%83%83%E3%82%B8%E3%81%AE%E4%BD%9C%E6%88%90) をご参照ください


![image (35).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/37b9afbc-c637-f065-5687-ba9b6a2f02e8.png)



#### コントローラ


ここでは「Show」と「Submit」という２つのパイプラインを作成しています。
Showでは初期のフォームを表示します。
Submitでは入力されたフォームの内容を受け取り、バリデーションを行い、バリデーションエラーがある場合はエラー画面を表示し、問題ない場合は、カスタムオブジェクトに情報を登録し、完了画面を返します。
またミドルウェアで `csrfProtection` や `server.middleware.https` モジュールを利用してCSRFトークンの生成・検証や、HTTPS以外を受け付けないように制御しています。

```js:controllers/FormTest.js
"use strict";

var server = require("server");
var csrfProtection = require("*/cartridge/scripts/middleware/csrf");

/**
 * フォームの表示
 */
server.get(
    "Show",
    server.middleware.https,
    csrfProtection.generateToken,
    function (req, res, next) {
        // フォームの取得
        var inquiryForm = server.forms.getForm("formTest");

        // セッションからフォーム情報のクリア
        inquiryForm.clear();

        // ログインしていたらメールアドレスを入力
        if (req.currentCustomer.profile) {
            inquiryForm.email.htmlValue = req.currentCustomer.profile.email
        }

        // 新規のフォームを表示
        res.render("formTest/inquiryForm", {
            inquiryForm: inquiryForm,
        });

        next();
    }
);

/**
 * フォームの内容のバリデーションとデータベース登録
 */
server.post(
    "Submit",
    csrfProtection.validateRequest,
    csrfProtection.generateToken,
    function (req, res, next) {
        // フォームの取得
        var inquiryForm = server.forms.getForm("formTest");

        // バリーデーションエラー
        // エラー結果をフォームに表示する
        if (!inquiryForm.valid) {
            res.render("formTest/inquiryForm", {
                inquiryForm: inquiryForm,
            });
            return next();
        }

        // バリデーションOK
        // カスタムオブジェクトデータベースに登録し、完了画面を表示する
        this.on("route:BeforeComplete", function (req, res) {
            // wrapを利用した暗黙的なデータベーストランザクション
            var Transaction = require("dw/system/Transaction");
            Transaction.wrap(function () {
                var CustomObjectMgr = require("dw/object/CustomObjectMgr");
                var id =
                    new Date().getTime() + "_" + inquiryForm.email.htmlValue;
                var CustomObject = CustomObjectMgr.createCustomObject(
                    "CustomInquiry",
                    id
                );
                CustomObject.custom.email = inquiryForm.email.htmlValue;
                CustomObject.custom.type = inquiryForm.type.htmlValue;
                CustomObject.custom.message = inquiryForm.message.htmlValue;
            });

            // 必要であればここでメールやSalckでの通知などをする

            // 完了ページを表示
            res.render("formTest/inquiryComplete");
        });

        next();
    }
);

module.exports = server.exports();
```



#### フォーム



このファイルはフォームのバリデーション条件や表示ラベル・メッセージの設定を行います。
`formTest.~` で始まる部分はのちに紹介する言語リソース `froms.propeties` ファイルのIDになっています。
また、typeのoptionで指定しているvalueはカスタムオブジェクト設定の際にtypeの選択肢の属性と指定した値で、デフォルトの値はdefault-valueにてRETURN_REFUND（返品・返金）を設定してあります。


```xml:forms/default/formTest.xml
<?xml version="1.0"?>
<form >

    <field formid="email" label="formTest.label.email" type="string" mandatory="true" max-length="50" regexp="^[\w.%+-]+@[\w.-]+\.[\w]{2,6}$" description="formTest.description.email"
        missing-error="formTest.error.missing"
        parse-error="formTest.error.parse"
        range-error="formTest.error.range"
        value-error="formTest.error.invalid"
    />

    <field formid="type" label="formTest.label.type" type="string" mandatory="true" description="formTest.description.type"
        missing-error="formTest.error.missing"
        value-error="formTest.error.invalid"
        parse-error="formTest.error.parse"
        default-value="RETURN_REFUND"
    >
        <options>
            <option optionid="SHIPPING" label="formTest.label.type.shipping" value="SHIPPING"/>
            <option optionid="RETURN_REFUND" label="formTest.label.type.return_refund" value="RETURN_REFUND"/>
            <option optionid="OTHER" label="formTest.label.type.other" value="OTHER" />
        </options>
    </field>


    <field formid="message" label="formTest.label.message" description="formTest.description.message" type="string" mandatory="true" max-length="1000"
        missing-error="formTest.error.missing"
        range-error="formTest.error.range"
    />

    <action formid="post" valid-form="true"/>
</form>
```



#### ISMLファイル

ここでは２つのISMLファイルを作成しますが、両方ヘッダーやフッターとしてはSFRAにデフォルトで入っている `common/layout/page` レイアウトを利用しています。
このレイアウトにはbootstrap 4.3.1 が入っているので、class名については [bootstrapの公式ドキュメント](https://getbootstrap.jp/docs/4.3/components/forms/)を参照してください。


このテンプレートはフォームの初期表示とエラー表示の両方で利用します。
`pdict.inquiryForm` オブジェクトの内容をもとに表示を切り替えています。
また、`pdict.csrf.tokenName` を利用してCSFRトークンを送信できるようにしています。

```html:templates/default/formTest/inquiryForm.isml
<isdecorate template="common/layout/page">

    <div class="container" style="padding: 2rem">

        <div class="row">
            <div class="col-12 align-self-center text-center">
                <h2>${Resource.msg('inquiryForm.title', 'formTest', null)}</h2>
            </div>

        </div>


        <div class="row">
            <div class="col-12">
                <form action="${URLUtils.url('FormTest-Submit')}" method="POST">

                    <div class="card-body">

                        <div class="form-group ${pdict.inquiryForm.email.mandatory === true ? 'required' : ''}">
                            <label class="form-control-label" for="email">
                                <isprint value="${pdict.inquiryForm.email.label}" encoding="htmlcontent" />
                            </label>
                            <small id="emailHelp" class="form-text text-muted">
                                <isprint value="${pdict.inquiryForm.email.description}" encoding="htmlcontent" />
                            </small>
                            <input type="text" class="form-control ${pdict.inquiryForm.email.valid ? '' : 'is-invalid'}"
                                id="email" name="${pdict.inquiryForm.email.htmlName}"
                                value="${pdict.inquiryForm.email.htmlValue}">
                            <div class="invalid-feedback">
                                <isif condition="${!pdict.inquiryForm.email.valid && pdict.inquiryForm.email.error}">
                                    <isprint value="${pdict.inquiryForm.email.error}" encoding="htmlcontent" />
                                </isif>
                            </div>
                        </div>


                        <div class="form-group ${pdict.inquiryForm.type.mandatory === true ? 'required' : ''}">
                            <label class=" form-control-label" for="type">
                                <isprint value="${pdict.inquiryForm.type.label}" encoding="htmlcontent" />
                            </label>
                            <select class="form-control ${pdict.inquiryForm.type.valid ? '' : 'is-invalid'}" id="type"
                                autocomplete="type" name="${pdict.inquiryForm.type.htmlName}">
                                <isloop items=${pdict.inquiryForm.type.options} var="type">
                                    <option id="${type.id}" value="${type.htmlValue}" ${type.selected ? "selected" : ""}>
                                        ${type.label}</option>
                                </isloop>
                            </select>
                            <div class="invalid-feedback">
                                <isif condition="${!pdict.inquiryForm.type.valid && pdict.inquiryForm.type.error}">
                                    <isprint value="${pdict.inquiryForm.type.error}" encoding="htmlcontent" />
                                </isif>
                            </div>
                        </div>

                        <div class="form-group ${pdict.inquiryForm.message.mandatory === true ? 'required' : ''}">
                            <label class="form-control-label" for="message">
                                <isprint value="${pdict.inquiryForm.message.label}" encoding="htmlcontent" />
                            </label>
                            <small id="messageHelp" class="form-text text-muted">
                                <isprint value="${pdict.inquiryForm.message.description}" encoding="htmlcontent" />
                            </small>
                            <textarea class="form-control ${pdict.inquiryForm.message.valid ? '' : 'is-invalid'}"
                                id="message" rows="5"
                                name="${pdict.inquiryForm.message.htmlName}">${pdict.inquiryForm.message.htmlValue}</textarea>
                            <div class="invalid-feedback">
                                <isif
                                    condition="${!pdict.inquiryForm.message.valid && pdict.inquiryForm.message.error}">
                                    <isprint value="${pdict.inquiryForm.message.error}" encoding="htmlcontent" />
                                </isif>
                            </div>

                        </div>

                        <div class="row">
                            <div class="col">
                                <button type="submit" name="post"
                                    class="btn btn-save btn-block btn-primary">${Resource.msg('formTest.label.submit', 'forms', null)}</button>
                            </div>
                        </div>
                    </div>

                    <input type="hidden" name="${pdict.csrf.tokenName}" value="${pdict.csrf.token}" />
                </form>

            </div>
        </div>
    </div>
</isdecorate>
```



このページは完了ページを表示するのに利用します。

```html:templates/default/formTest/inquiryComplete.isml
<isdecorate template="common/layout/page">
    <div class="container" style="padding: 2rem">
        <div class="row">
            <div class="col-12">
                <div class="card text-center">
                    <div class="card-body">
                        <h5 class="card-title">${Resource.msg('inquiryComplete.title', 'formTest', null)}</h5>
                        <p class="card-text">${Resource.msg('inquiryComplete.description', 'formTest', null)}</p>
                        <a href="${URLUtils.url('Home-Show')}" class="btn btn-primary">${Resource.msg('inquiryComplete.backHome', 'formTest', null)}</a>
                    </div>
                </div>
            </div>
        </div>
    </div>
</isdecorate>
```



#### 言語ファイル

今回はデフォルトの言語のみの設定ですが、日本語や英語などの言語毎に言語ファイルを設定することができます。詳細は[Infocenter](https://documentation.b2c.commercecloud.salesforce.com/DOC1/topic/com.demandware.dochelp/content/b2c_commerce/topics/localization/b2c_localizing_template_resource_bundles.html?resultof=%22%6c%6f%63%61%6c%22%20)をご覧ください。



このファイル名を利用することで `forms` フォルダ内でこの言語ファイルを利用することができます。
今回の場合で言うと `forms/default/formTest.xml` 内でここで指定したIDを利用しています。

```properties:templates/resources/forms.properties
# Label
formTest.label.email=Eメール
formTest.label.type=お問い合わせ種別
formTest.label.message=お問い合わせ内容
formTest.label.submit=送信

# Description
formTest.description.email=返信用に利用するEメールを入力してください。
formTest.description.type=お問い合わせ内容の種別を選択してください。
formTest.description.message=お問い合わせのないよの詳細を記入してください。

# Error
formTest.error.parse=フォーマットが正しくありません
formTest.error.invalid=無効な値です
formTest.error.range=文字数が正しくありません
formTest.error.missing=必須項目です

# Type Options
formTest.label.type.shipping=配送
formTest.label.type.return_refund=返品・返金
formTest.label.type.other=その他
```


このファイルはISMLファイルで `Resource.msg('inquiryComplete.description', 'formTest', null)` のように呼び出すために作成した言語ファイルです。

```properties:templates/resources/formTest.properties
inquiryForm.title=お問い合わせ
inquiryComplete.title=お問い合わせ完了
inquiryComplete.description=お問合せを正常に受け付けました。返信が届くまで少々お待ちください。
inquiryComplete.backHome=ホームへ戻る
```




## 作成したフォームを試してみる

ログインした状態で、ブラウザで `https://{ドメイン}/on/demandware.store/Sites-RefArch-Site/ja_JP/FormTest-Show`にアクセスしてみると、EメールにはアカウントのEメールアドレスが入力されています。

![スクリーンショット 2022-07-13 16.03.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/5ccb1150-083c-ffef-108f-08cd4d561843.png)



お問い合わせに「テストです。」と入力し、送信すると完了ページが現れます。

![image (36).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/aef924f1-e0e0-c1e3-8e64-c74ffc3262ab.png)



Business Managerの **マーチャントツール > カスタムオブジェクト > カスタムオブジェクトの管理** に遷移し、登録されているか確認します。今回はIDとしてタイムスタンプ+Eメールの形式で登録しています。（内容はFotmTest.js:61を参照）


![スクリーンショット 2022-07-13 16.09.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/0a2ccde8-8a02-4663-215c-df3414e5d4e5.png)

![スクリーンショット 2022-07-13 16.09.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/3a57ecd0-8265-bb51-c59a-ea1d53e29458.png)