BLOG

ブログ
  • Web制作

MW WP Form脱却で辿り着いた「Contact Form 7 + JS自作」確認画面という結論

MW WP Form脱却で辿り着いた「Contact Form 7 + JS自作」確認画面という結論

採用サイトのエントリーフォームで長年愛用してきたMW WP Formですが、2026年にファイル添付機能([mwform_file])が廃止されるというアナウンスがありました。履歴書PDFの添付が必須になる採用サイトの案件にとっては、これは大問題です。

今回、MW WP Formから別のフォームプラグインの乗り換えを検討し、いくつかの候補を試した末に「Contact Form 7 + 自作JS確認画面」という構成に着地しました。この記事では、その検討過程と最終的な実装内容をまとめます。

必要だった要件

乗り換え先のプラグインに求めた要件はシンプルに2つでした。

  • 入力画面 → 確認画面 → サンクス画面の3ステップ構成
  • ファイル添付機能(履歴書PDF・画像など)
  • Classic Editorとの併用

候補①:Snow Monkey Forms → ブロックエディター前提の壁

MW WP Formと同じ開発者による後継プラグイン「Snow Monkey Forms」は、確認画面・完了画面が標準搭載されており、ファイル添付もブロックで簡単に設定できます。機能面では申し分ありません。

しかし、案件ではClassic Editorプラグインを使って旧エディター(TinyMCE)を有効化しているという前提があり、ここで壁にぶつかりました。

Snow Monkey Formsはブロックエディター(Gutenberg)が前提のプラグインで、Classic Editorで「置き換え」設定をしていると、Snow Monkey Formsのフォーム編集画面自体もクラシックエディターになってしまい、そもそもフォームを作成できません。

回避策として、投稿タイプ単位でエディターを出し分けるフィルターフックを重ねて試しました。

// WordPressコア:個別の投稿を編集する時の判定
add_filter( 'use_block_editor_for_post', function( $use_block_editor, $post ) {
    if ( 'snow-monkey-forms' === $post->post_type ) {
        $use_block_editor = true;
    }
    return $use_block_editor;
}, 10, 2 );

// WordPressコア:投稿タイプ全体の判定
add_filter( 'use_block_editor_for_post_type', function( $use_block_editor, $post_type ) {
    if ( 'snow-monkey-forms' === $post_type ) {
        return true;
    }
    return $use_block_editor;
}, 10, 2 );

// Classic Editorプラグイン独自の判定:投稿タイプ単位
add_filter( 'classic_editor_enabled_editors_for_post_type', function( $editors, $post_type ) {
    if ( 'snow-monkey-forms' === $post_type ) {
        $editors['classic_editor'] = false;
        $editors['block_editor']   = true;
    }
    return $editors;
}, 10, 2 );

// Classic Editorプラグイン独自の判定:個別投稿単位
add_filter( 'classic_editor_enabled_editors_for_post', function( $editors, $post ) {
    if ( 'snow-monkey-forms' === $post->post_type ) {
        $editors['classic_editor'] = false;
        $editors['block_editor']   = true;
    }
    return $editors;
}, 10, 2 );

ポイントは、Classic Editorプラグインには「プラグイン独自の判定フィルター」があり、WordPressコア標準のuse_block_editor_for_post_typeだけでは制御しきれないということです。Classic Editorが強制置き換えモードで動いている場合、コアの判定より先にプラグイン自身の設定で画面が決まってしまうため、classic_editor_enabled_editors_for_post_type / classic_editor_enabled_editors_for_postもあわせて上書きする必要があります。

この構成自体は機能しますが、投稿タイプのスラッグ(smf-formなのかsnow-monkey-formsなのか等、バージョンによって差異あり)を都度確認する手間や、コードの複雑さを考えると、案件ごとに毎回仕込むには少し重く感じました。

候補②:Contact Form 7 + Multi-Step Forms → ファイル添付の壁

使い慣れたContact Form 7(以下CF7)に、確認画面を追加できる「Contact Form 7 Multi-Step Forms」も検討しました。

しかしこのプラグインは、ステップごとに別ページへ遷移する仕組みのため、ファイル添付との相性がよくありません。確認画面を挟むと、添付したファイルが送れない問題が発生。また無料版はデータ容量が4KBまでという制限も影響しています。

回避策はあるものの確実とは言えず、案件のたびに動作確認が必要になりそうでした。

最終的な結論:Contact Form 7 + 自作JS確認画面

行き着いた結論は、プラグインに頼らず、同一ページ内でJSによる表示切り替えだけで確認画面を作るという方法です。

これなら、

  • 使い慣れたCF7のタグ設計をそのまま使える
  • ページ遷移を挟まないので、<input type="file">のFileListが保持されたまま送信される=ファイル添付が壊れない
  • Classic Editor環境でも一切問題にならない(CF7自体はクラシック・ブロックどちらでも編集画面に依存しない)

という、当初の懸念点をすべて回避できます。

実装の全体像

  • 入力画面・確認画面をそれぞれdivで用意し、確認画面は初期状態でdisplay:none
  • 「確認する」ボタン押下時、JSでバリデーション&値のコピーを行い、表示を切り替える(この時点ではまだ送信していない)
  • 確認画面の「送信する」ボタンは、CF7本来の[submit]タグそのもの
  • wpcf7mailsentイベントを検知して、サンクスページへリダイレクト

フォーム側のマークアップ(CF7のフォームタブ)

<!--入力画面-->
<div class="input-area" data-thanks-url="/entry/entry-thanks/?from=form">
    <div class="form-item-wrap">
        <div class="form-item">
            <div class="form-item-label"><span class="required">必須</span>希望種別</div>
            <div class="form-item-input">
                [select* job_category id:job_category first_as_label "選択してください" "選択肢1" "選択肢2" "選択肢3"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label"><span class="required">必須</span>氏名</div>
            <div class="form-item-input">
                [text* your_name id:your_name placeholder "山田太郎"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label"><span class="required">必須</span>フリガナ</div>
            <div class="form-item-input">
                [text* your_furigana id:your_furigana placeholder "ヤマダタロウ"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">性別</div>
            <div class="form-item-input gender">
                [radio your_gender id:your_gender use_label_element "男性" "女性" "回答しない"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label"><span class="required">必須</span>生年月日</div>
            <div class="form-item-input">
                [date* your_bd id:your_bd "2004-01-01"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label"><span class="required">必須</span>メールアドレス</div>
            <div class="form-item-input">
                [email* email id:email placeholder "sample@sample.co.jp"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label"><span class="required">必須</span>メールアドレス(確認用)</div>
            <div class="form-item-input">
                [email* email_confirm id:email_confirm placeholder "sample@sample.co.jp"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label"><span class="required">必須</span>電話番号</div>
            <div class="form-item-input">
                [tel* your_tel id:your_tel placeholder "00000000000"]
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">履歴書</div>
            <div class="form-item-input">
                <div class="upload_file-wrap">
                    [file rirekisho id:rirekisho filetypes:audio/*|video/*|image/* limit:5mb]
                </div>
                <div class="upload_file__note">※ファイルサイズの上限は5MBです ※拡張子はjpg、png、pdfでご用意ください</div>
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">自己PR/志望動機 <span class="note">※300文字以内</span></div>
            <div class="form-item-input">
                [textarea your_pr id:your_pr class:js-charcount placeholder] お問い合わせ内容をご記入ください。 [/textarea]
            </div>
        </div>
    </div>
    <div class="agree">
        <div class="input-wrap">
            [checkbox* agree id:agree "同意する"]
        </div>
        <p><a target="_blank" href="./privacy-policy/" rel="noopener">個人情報保護方針</a>に同意する</p>
    </div>
    <div class="button-wrap">
        <div class="submit">
            <input type="button" class="confirm_button" value="確認する">
        </div>
    </div>
</div>

<!--確認画面-->
<div class="input-area confirm" style="display: none;">
    <div class="form-item-wrap">
        <div class="form-item">
            <div class="form-item-label">希望種別</div>
            <div class="form-item-input" data-confirm="job_category">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">氏名</div>
            <div class="form-item-input" data-confirm="your_name">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">フリガナ</div>
            <div class="form-item-input" data-confirm="your_furigana">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">性別</div>
            <div class="form-item-input" data-confirm="your_gender">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">生年月日</div>
            <div class="form-item-input" data-confirm="your_bd">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">メールアドレス</div>
            <div class="form-item-input" data-confirm="email">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">メールアドレス</div>
            <div class="form-item-input" data-confirm="email_confirm">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">電話番号</div>
            <div class="form-item-input" data-confirm="your_tel">
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">履歴書</div>
            <div class="form-item-input">
                <div class="upload_file-wrap" data-confirm="rirekisho">
                </div>
            </div>
        </div>
        <div class="form-item">
            <div class="form-item-label">自己PR/志望動機</div>
            <div class="form-item-input" data-confirm="your_pr">
            </div>
        </div>
    </div>
    <div class="button-wrap">
        <div class="submit">
            [submit "送信する"]
        </div>
        <div class="back">
            <button type="button" class="btn-back">入力内容を修正する</button>
        </div>
    </div>
</div>

入力画面の各項目に対応する形で、確認画面側にはdata-confirm="タグ名"を振った空のdivを用意しておくのがポイントです。これにより、JS側はdata-confirm属性を頼りに機械的に値を差し込めます。

JS本体

document.addEventListener("DOMContentLoaded", function () {
	document.querySelectorAll(".wpcf7-form").forEach(function (form) {
		const inputWrap = form.querySelector(".input-area:not(.confirm)");
		const confirmWrap = form.querySelector(".input-area.confirm");

		if (!inputWrap || !confirmWrap) return;

		const confirmBtn = inputWrap.querySelector(".confirm_button");
		const backBtn = confirmWrap.querySelector(".btn-back");
		const realSubmit = confirmWrap.querySelector('input[type="submit"]');

		if (!confirmBtn || !backBtn) {
			console.warn("確認画面用の要素が見つかりません");
			return;
		}

		const thanksUrl = inputWrap.dataset.thanksUrl || "/thanks/";

		function getField(name) {
			return form.querySelector('[name="' + name + '"], [name="' + name + '[]"]');
		}

		function getTextValue(name) {
			const el = getField(name);
			return el ? el.value.trim() : "";
		}

		function getSelectLabel(name) {
			const el = getField(name);
			if (!el) return "";

			const selected = el.options[el.selectedIndex];
			return selected ? selected.textContent.trim() : "";
		}

		function getRadioLabel(name) {
			const checked = form.querySelector('[name="' + name + '"]:checked');
			if (!checked) return "(未選択)";

			const label = checked.closest("label");
			return label ? label.textContent.trim() : checked.value;
		}

		function getFileName(name) {
			const el = getField(name);
			if (!el || !el.files || el.files.length === 0) return "(添付なし)";

			return el.files[0].name;
		}

		function nl2br(text) {
			return text.replace(/\n/g, "<br>");
		}

		function clearErrors() {
			form.querySelectorAll(".wpcf7-not-valid-tip").forEach(function (el) {
				el.remove();
			});

			form.querySelectorAll(".wpcf7-not-valid").forEach(function (el) {
				el.classList.remove("wpcf7-not-valid");
				el.removeAttribute("aria-invalid");
			});
		}

		function showError(name, message) {
			const el = getField(name);
			if (!el) return;

			el.classList.add("wpcf7-not-valid");
			el.setAttribute("aria-invalid", "true");

			const tip = document.createElement("span");
			tip.className = "wpcf7-not-valid-tip";
			tip.textContent = message;

			const wrap = el.closest(".form-item-input") || el.closest(".input-wrap") || el.parentNode;
			wrap.appendChild(tip);
		}

		function validateConfirm() {
			clearErrors();

			let hasError = false;

			form.querySelectorAll(".wpcf7-validates-as-required").forEach(function (el) {
				const name = el.name ? el.name.replace("[]", "") : "";
				if (!name || name === "agree") return; // ← agreeはここでスキップして②に一本化

				if (el.type === "checkbox" || el.type === "radio") {
					const checked = form.querySelector('[name="' + name + '"]:checked, [name="' + name + '[]"]:checked');
					if (!checked) {
						showError(name, "入力してください。");
						hasError = true;
					}
					return;
				}

				if (!el.value.trim()) {
					showError(name, "入力してください。");
					hasError = true;
				}
			});

			const agreeField = form.querySelector('input[type="checkbox"][name="agree[]"], input[type="checkbox"][name="agree"]');
			const agreeChecked = form.querySelector('input[type="checkbox"][name="agree[]"]:checked, input[type="checkbox"][name="agree"]:checked');

			if (agreeField && !agreeChecked) {
				agreeField.classList.add("wpcf7-not-valid");
				agreeField.setAttribute("aria-invalid", "true");

				const tip = document.createElement("span");
				tip.className = "wpcf7-not-valid-tip";
				tip.textContent = "同意が必要です。";

				const agreeWrap = agreeField.closest(".agree");

				if (agreeWrap) {
					agreeWrap.querySelectorAll(".wpcf7-not-valid-tip").forEach(function (el) {
						el.remove();
					});
					agreeWrap.appendChild(tip);
				}

				hasError = true;
			}

			if (getTextValue("email") && getTextValue("email_confirm") && getTextValue("email") !== getTextValue("email_confirm")) {
				showError("email_confirm", "メールアドレスが一致しません。");
				hasError = true;
			}

			if (hasError) {
				const firstError = form.querySelector(".wpcf7-not-valid");
				if (firstError) {
					firstError.scrollIntoView({ behavior: "smooth", block: "center" });
				}
				return false;
			}

			return true;
		}

		function getConfirmValue(name) {
			const field = getField(name);
			if (!field) return "";

			if (field.type === "radio") {
				return getRadioLabel(name);
			}

			if (field.type === "file") {
				return getFileName(name);
			}

			if (field.tagName === "SELECT") {
				return getSelectLabel(name);
			}

			return getTextValue(name);
		}

		function fillConfirmScreen() {
			confirmWrap.querySelectorAll("[data-confirm]").forEach(function (el) {
				const name = el.dataset.confirm;
				if (!name) return;

				const field = getField(name);
				if (!field) return;

				if (field.tagName === "TEXTAREA") {
					el.innerHTML = nl2br(getTextValue(name));
				} else {
					el.textContent = getConfirmValue(name);
				}
			});
		}

		confirmBtn.addEventListener("click", function (e) {
			e.preventDefault();

			if (!validateConfirm()) return;

			fillConfirmScreen();

			inputWrap.style.display = "none";
			confirmWrap.style.display = "block";

			window.scrollTo(0, 0);
		});

		backBtn.addEventListener("click", function () {
			confirmWrap.style.display = "none";
			inputWrap.style.display = "block";

			window.scrollTo(0, 0);
		});

		form.addEventListener("submit", function () {
			if (realSubmit) {
				realSubmit.disabled = true;
			}
		});

		form.addEventListener("wpcf7mailsent", function () {
			window.location.href = thanksUrl;
		});

		form.addEventListener("wpcf7invalid", function () {
			if (realSubmit) {
				realSubmit.disabled = false;
			}

			confirmWrap.style.display = "none";
			inputWrap.style.display = "block";
		});

		form.addEventListener("wpcf7mailfailed", function () {
			if (realSubmit) {
				realSubmit.disabled = false;
			}

			alert("送信に失敗しました。時間をおいて再度お試しください。");
		});
	});
});

実装のポイントまとめ

  • ファイル添付が壊れない理由<input type="file">をDOM上で複製・再生成せず、確認画面へは「表示だけ」切り替えている。実際のPOSTは1回だけなので、FileListが保持されたまま送信される。
  • 同意チェックボックスの罠:CF7でcheckbox*タグにidを指定すると、idが付くのは実際の<input>ではなくラップしている<span>側。form.querySelector('#agree')のようにIDだけで拾うと.checkedが取得できないため、input[name="agree[]"]のようにname属性から辿る必要がある。
  • 送信ボタンの実体:確認画面の「送信する」ボタンはCF7の[submit]タグそのもの。JSから別ボタンを疑似クリックさせる必要はなく、素直にクリックさせればCF7がAjax送信してくれる。
  • サンクスページへの遷移wpcf7mailsentイベントでwindow.location.hrefによりリダイレクト。直接アクセス対策として?from=formのようなクエリを付与し、サンクスページ側で判定する運用も可能。
  • バリデーションエラー表示:CF7標準のwpcf7-not-valid / wpcf7-not-valid-tipクラスをそのまま流用することで、既存のCF7用エラーCSSが使い回せる。

まとめ

MW WP Formのファイル添付機能廃止をきっかけに、Snow Monkey Forms・Contact Form 7 Multi-Step Formsと2つの乗り換え候補を検証しましたが、それぞれ「Classic Editorとの相性」「ファイル添付との相性」という構造的な壁にぶつかりました。

最終的に選んだ「Contact Form 7 + 自作JS確認画面」という構成は、一見遠回りに見えて、実は一番シンプルで確実な解決策でした。

  • CF7のタグ設計は今まで通り使い回せる
  • Classic Editor環境でも問題なく動く
  • ファイル添付も確認画面も両立できる

この構成はテンプレート化しておけば、フィールド名の差し替えだけで案件ごとに使い回せます。採用サイトのようにファイル添付が絡む案件が多い制作者の方には、選択肢の一つとしておすすめです。