さぁ!検索しよう!

ECサイトの仕組みが知りたい&WordPressでプラグインを利用せずにECサイトのCMSを構築してみたいと思い、このような記事を書くに至りました。

第1回は、商品の投稿機能を実装する方法です。

尚、index.phpやsingle.php等のテンプレートは既に用意されているものとします。

クラスを作成する

今回行うことは全て一つのクラスに集約します。それでは始めにCustomFieldNameクラスの骨組みを作成します。

functions.php

class CustomFieldName {
    function __construct() {

    }

    function add_custom_inputbox() {

    }

    function name_custom_field() {

    }

    function admin_scripts($hook_suffix) {

    }

    function save_custom_postdata($post_id) {

    }
}

これからコンストラクタにはアクションを、メソッドには機能を追加していきます。

メタボックスを追加する

商品の編集画面内に商品情報を入力する領域であるメタボックスを追加します。

CustomFieldNameクラスのadd_custom_inputboxメソッドを以下のようにします。

functions.php

function add_custom_inputbox() {
    add_meta_box(
        'product_info',
        '商品情報',
        array($this, 'name_custom_field'),
        'post',
        'normal'
    );
}

このメソッドが実行されるとadd_meta_box関数によって管理画面の投稿ページにメタボックスが追加されます。add_meta_box関数の引数は次の通りです。

add_meta_box(HTML ID, 画面上に表示されるタイトル, HTMLを出力する関数, 投稿タイプ、編集画面内で表示される場所);

add_meta_box関数の第三引数に指定したname_custom_field関数でメタボックス内に入力フィールドを出力します。

name_custom_field関数を以下の順で完成させます。

フォームに入力した値を取得する

get_post_meta関数で現在編集中の投稿のフォームに入力した値を取得します。

functions.php

function name_custom_field() {
    $id = get_the_ID();
    $productName = esc_attr(get_post_meta($id, 'name', true));
    $productPrice = esc_attr(get_post_meta($id, 'price', true));
    $productStock = esc_attr(get_post_meta($id, 'stock', true));
    $productDescription = esc_attr(get_post_meta($id, 'description', true));
    $productImage = get_post_meta($id, 'image', true);

入力フォームは後で定義します。

nonceを生成する

フォーム内にnonceを生成します。

functions.php

wp_nonce_field(wp_create_nonce(__FILE__), 'my_nonce');

nonceとは一度だけ使われる値であり、wp_create_nonce関数で生成されます。

そしてwp_nonce_field関数でtypeがhidden、nameがmy_nonce、そしてnonceをvalueに持つinput要素が生成されます。

<input type="hidden" name="my_nonce" value="wp_create_nonce(__FILE__)">

このinput要素をフォームで送り、送り先で正しい送信であるかを判断します。

こちら側で生成したnonceと送られてきたnonceが一致していれば正しい送信であると判断し、一致しなければ不正な操作による送信と判断します。

入力フィールドを定義する

各入力フィールドを定義します。

functions.php

echo <<<EOS
<div class="product-info">
    <p>商品の情報を以下に入力してください。</p>
    <p>
        <label for="pname" class="label label-name">商品名:必須項目</label><br>
        <input id="pname" class="field" type='text' name='productName' value='{$productName}'><br>
        <span class="msg msg-name text-danger"></span>
    </p>
    <p>
        <label for="pprice" class="label label-price">価格(半角数字のみ):必須項目</label><br>
        <input id="pprice" class="field field-price" type='text' name='productPrice' value='{$productPrice}'>円<br>
        <span class="msg msg-price text-danger"></span>
    </p>
    <p>
        <label for="pstock" class="label label-stock">在庫数(半角数字のみ):必須項目</label><br>
        <input id="pstock" class="field field-stock w-100" type='text' name='productStock' value='{$productStock}'>個<br>
        <span class="msg msg-stock text-danger"></span>
    </p>
    <p>
        <label for="pdescription" class="label label-description">商品の説明(140字以内)</label><br>
        <p id="num" class="text-right">0</p>
        <textarea id="pdescription" class="field field-description w-100" name="productDescription" size="140">{$productDescription}</textarea><br>
        <span class="msg msg-description text-danger"></span>
    </p>

入力項目は商品名・価格・在庫数・商品の説明・画像です。尚、画像の入力フィールドは後で定義します。

inputのvalueにget_post_meta関数で先程取得した値を指定することで、入力フィールドに入力した値がそのままvalueに入ります。

.msgには、入力エラーがあったときにエラーメッセージを出力するための要素です。

#numには商品の説明を入力中に現在の文字数を出力します。そのため初めは0となっています。

画像の入力フィールドを定義する

画像の情報を送るための入力フィールドを定義します。

functions.php

<p>
                <label for="" class="label label-image">商品画像(JPEG画像のみ&5枚まで)</label><br>
                <div class="wrap">
                    <button id="image-choose" type="button" class="button">画像を選択</button>
                    <div id="images" class="row mt-2">
EOS;
                    if(!empty($productImage)) {
                        foreach($productImage as $akey => $imgID) {
                            $imgID = esc_attr($imgID);
                            if(isset( $imgID ) && !is_array( $imgID )) {
                                $img = wp_get_attachment_image($imgID, 'full', false, array('class' => 'img-fluid'));
                                preg_match_all('/<img.*src\s*=\s*[\"|\'](.*?)[\"|\'].*>/i', $img, $matches);
                                list($img_width, $img_height, $mime_type, $attr) = getimagesize($matches[1][0]);
                                echo <<<EOS
                                    <div id="img_{$akey}" class="col-6 col-sm-4 col-md-3 mb-2">
                                        <a href="#" class="image-remove">削除する</a>
                                        <br />
                                        <input type="hidden" accept="image/jpeg" name="productImage[]" value='{$imgID}' />
                                        <div class="responsive-image bg-light">{$img}</div>
EOS;
                                if($mime_type !== 2) {
                                    echo "<p class='msg msg-image msg-image-1 text-danger'>JPEG画像ではありません。</p>";
                                }                                       
                                echo '</div>';
                            }
                        }
                    }
                    echo <<<EOS
                    </div>
                    <p class="msg msg-image msg-image-2 text-danger"></p>
                    <p class="msg msg-image msg-image-3 text-danger"></p>
                </div>
            </p>
        </div>
EOS;
}

get_post_meta関数で、現在投稿画面のメタボックス内にサムネイル表示されている画像の情報を持つIDを全て取得し、これを$productImageとします。

アップロードできる画像の種類はJPEGのみ且つ、アップロードは一つの投稿につき5枚までとします。

#image-chooseは「画像を選択」ボタンです。このボタンをクリックするとメディアアップローダーが開き、アップロードする画像を選択できます。

if(!empty($productImage)) {}で、$productImageが空ではない、つまり以前に画像がアップロードされていれば、それらの画像をサムネイル表示します。

画像をサムネイル表示するには、画像ID$productImageをforeach文で回していき、$imgIDとして一つずつ取得していきます。そして、$imgIDが存在し且つ$imgIDが配列ではない場合に、wp_get_attachment_image関数で$imgIDから画像要素を生成し、これを$imgとします。

preg_match_all関数で、生成した画像要素$imgを検索して、マッチした全ての文字列を$machesに代入します。

$matchesは以下のような配列になっています。

array (size=2)
  0 => 
    array (size=1)
      0 => string '<img width="640" height="640" src="画像のURL" />' (length=445)
  1 => 
    array (size=1)
      0 => string '画像のURL' (length=74)

関数で$matchesから画像$imgの拡張子$mime_typeを取り出します。

一つのサムネイル#img_{$akey}は以下のような構造になっています。

  • サムネイルの上に「削除する」リンクを表示しています。

  • 投稿を公開又は更新したときに、選択した画像をデータベースに保存(アップロード)するために、valueに画像ID$imgIDが入ったinput[type=”hidden”]要素を定義しています。

  • .responsive-image内にサムネイル画像を出力しています。

  • アップロードした画像の拡張子$min_typeが2ではない、つまりJPEGでなければエラーメッセージを表示します。

.msg-image-2.msg-image-3は画像が選択されていない、選択した画像が5枚を超えている場合のエラーメッセージとなります。

これでadd_custom_inputbox関数及びname_custom_field関数は完成ですが、画像のフィールドに関しては現時点で新規投稿画面か更新画面を表示したときの初めの状態です。なので、メディアアップローダーを開いて画像を選択し、選択した画像をサムネイル表示する機能は後で追加します。

最後にadd_action関数でadd_custom_inputbox関数をadmin_menuフックに登録して管理画面で実行されるように、コンストラクタを以下のようにします。

functions.php

function __construct() {
    add_action('admin_menu', array($this, 'add_custom_inputbox'));
}

コンストラクタはCustomFieldNameクラスがインスタンス化されたときに実行される初期化であります。

add_actionの書き方ですが、クラスで定義したため第二引数はarray($this, callback)のようにします。

メディアライブラリから画像を選択してサムネイル表示する

先程定義した画像の入力フィールドに「画像を選択」ボタンを押してメディアアップローダーを開き、メディアライブラリから選択した画像がサムネイル表示される機能を実装します。

これらの処理はJavaScriptで行います。それではmy-uploader.jsを以下の順で作成します。

変数を宣言する

初めに、自身で設定したアップローダーが入る変数custom_uploaderと選択された画像の総数imageLength、エラーメッセージを表示する要素msgImage3を宣言します。

my-uploader.js

jQuery(document).ready(function($) {
    let custom_uploader;
    let imageLength = $('#images').children().length;
    const msgImage3 = document.querySelector('.msg-image-3');

選択した画像の枚数を判定する関数を定義する

引数に選択した画像の総数を受け取り、総数が5枚より多いかそれ以下かを判定するためのoverLength関数を定義します。

my-uploader.js

  function overLength(length) {
        if(length > 5) {
            return true;
        }
        return false;
    }

メディアアップローダーを設定・開く

「画像を選択」ボタンをクリックしたときに、既にアップローダーの設定がされていればアップローダーを開いて、それ以降の処理は行いません。

my-uploader.js

$('#image-choose').click(function(e) {
        e.preventDefault();
        if(custom_uploader) {
            custom_uploader.open();
            return;
        }

反対に設定されていなければ、設定をしてからアップローダーを開きます。

my-uploader.js

    custom_uploader = wp.media({
            title: '画像を選択',
            library: {
                type: 'image'
            },
            button: {
                text: 'OK'
            },
            multiple: true
        });

        custom_uploader.open();

wp.media()でアップローダーの右側に表示する項目をオブジェクト形式で設定します。

multipletrueにすると画像を複数選択できるようになり、falseにすると一枚のみ選択できるようになります。

選択した画像を取得する

選択する画像にチェックを入れてOKボタンを押したときに、選択した画像を全て取得します。

my-uploader.js

    custom_uploader.on('select', function() {
        const images = custom_uploader.state().get('selection');

custom_uploader.state().get('selection')で、選択した画像をimagesとして取得します。

選択した画像をサムネイル表示する

選択した画像をメタボックス上にサムネイル表示します。

my-uploader.js

        const date = new Date().getTime();
                imageLength = $('#images').children().length + images.length;

                msgImage3.innerHTML = '';

                images.each(function(file){
                    img_id = file.toJSON().id + "_" + date;
                    $('#images').append('<div id=img_' + img_id + ' class="col-6 col-sm-4 col-md-3 mb-2"></div>')
                    .find('#img_' + img_id).append('<a href="#" class="image-remove">削除する</a><br />'
                        +'<input type="hidden" name="productImage[]" value="'+file.toJSON().id+'" />'
                        +'<div class="responsive-image bg-light"><img src="'+file.toJSON().sizes.full.url+'" class="img-fluid" /></div>'                    
                    );

new Date().getTime()でアップロードした日時を取得します。この日時はアップロードした画像のファイル名の一部にします。

選択した画像の枚数imagesに既に選択されていた画像の枚数を足して選択した画像の総数をimageLengthとして取得します。

msgImage3.innerHTML = '';でエラーメッセージの初期化を行います。

eachメソッドで、選択した画像imagesに対して一枚ずつ処理を行い、サムネイル化していきます。

コールバック関数の引数fileは現在の処理対象である画像です。

file.toJSON().idで画像情報を持つIDを取得後、それに日時dateを合わせた文字列をimg_idとして取得し、これをサムネイルの親要素のid属性に指定します。

#images内に以下の要素を挿入します。

<div id=img_' + img_id + ' class="col-6 col-sm-4 col-md-3 mb-2">
    <a href="#" class="image-remove">削除する</a><br />
    <input type="hidden" name="productImage[]" value="'+file.toJSON().id+'" />
    <div class="responsive-image bg-light">
        <img src="'+file.toJSON().sizes.full.url+'" class="img-fluid" />
    </div>
</div>

input要素のnameをproductImage[]とすることで、画像情報を持つIDfile.toJSON().idを一つのproductImageで配列でまとめて送信できます。

.responsive-image以下の要素はサムネイルです。img要素のsrc属性をfile.toJSON().sizes.full.urlとすることで、フルサイズの画像が出力されます。

親要素のクラス.responsive-imageとimg要素のクラス.img-fluidでレスポンシブなサムネイルにしています。

これらのクラスは後で<style></style>で直接出力します。

エラーメッセージを表示する

画像の拡張子がjpegではなければその画像の下にエラーメッセージ「JPEGファイルではありません。」を表示させます。また、選択した画像の総数が5枚より多ければエラーメッセージ「画像のアップロード枚数は5枚までです。」を表示します。

my-uploader.js

        if(file.toJSON().subtype !== 'jpeg') {
                    $('#img_'+img_id).append('<p class="msg msg-image msg-image-1 text-danger">JPEGファイルではありません。</p>');
                    $('#img_'+img_id).find('img').addClass('border border-danger');
                }

                if(overLength(imageLength)) {
                    $('.msg-image-2').html('画像のアップロード枚数は5枚までです。');
                }
            });
        });

$('#img_'+img_id)内のimg要素に.border.border-dangerクラスを追加して、エラーとなった画像を赤いボーダーで囲います。

overLength関数で選択した画像の総数imageLengthを判定しています。

サムネイルを削除できるようにする

各サムネイルの上に表示されているリンクをクリックすると、そのサムネイルが削除されるようにします。

my-uploader.js

    $(".image-remove").live('click', function(e) {
        e.preventDefault();
        e.stopPropagation();
        img_obj = $(this).parent();
        img_obj.remove();
        imageLength--;
        if(!overLength(imageLength)) {
            $('.msg-image-2').html("");
        }
    });
});

$(".image-remove")は「削除する」リンクです。

クリックされた「削除する」リンク自身$(this)を子に持つ親要素$(this).parent()、つまり$('#img_'+img_id)を削除します。また、削除したので総数imageLengthを一つ減らします。

削除後に総数が5枚以下になったらエラーメッセージを削除します。

my-uploader.jsについては以上です。

次にadmin_scripts関数に、メディアアップロードに関するファイルと先程作成したmy-uploader.jsを読み込むコード、そしてサムネイルのスタイルを追加します。

functions.php

function admin_scripts($hook_suffix) {
    if($hook_suffix == "post.php" || $hook_suffix == "post-new.php") {
        wp_enqueue_media();
        wp_enqueue_script(
            'my-media-uploader',
            get_bloginfo('stylesheet_directory').'/js/my-uploader.js',
            array('jquery'),
            filemtime(dirname(__FILE__).'/js/my-uploader.js'),
            false
        );
        echo <<< EOS
            <style type="text/css">
            .responsive-image {
                height:0;
                overflow:hidden;
                padding-top: 100%;
                position:relative;
                width:100%;
            }   
            .responsive-image img {
                position:absolute;
                top:0;
                left:0;
            }
            </style>
EOS;
    }
}

この関数は管理画面に適用するcssとjsをHTMLに出力します。

if($hook_suffix == "post.php" || $hook_suffix == "post-new.php") {}で、投稿の編集画面post.phpか新規投稿画面post-new.phpであるときのみファイルを出力するようにしています。

wp_enqueue_media関数はメディアアップローダーに関するCSS・JSファイルを出力します。

wp_enqueue_script関数でjsファイルmy-uploader.jsを出力します。

サムネイルのスタイルはレスポンシブ対応のみです。

尚、この関数は管理画面に適用するcssとjsを出力するので、admin_enqueue_scriptsフックに登録して管理画面で実行されるようにします。

コンストラクタに以下のアクションフックを追加します。

functions.php

add_action('admin_enqueue_scripts', array($this, 'admin_scripts'));

バリデーションを実装する

入力した値に誤りがあればエラーメッセージを表示して、公開・更新を止めるバリデーションを実装します。

バリデーションはJavaScriptで行います。my-post-validation.jsを以下の順で作成します。

投稿ページのみバリデーションを行う

公開又は更新ボタンを押したときに、現在表示しているページが投稿ページである場合のみバリデーションを行います。

my-post-validation.js

jQuery(document).ready(function($) {
    if($('#post_type').val() == 'post') {

    }

if($('#post_type').val() == 'post') {...}は、公開・更新ボタンを押した際に送信される、idがpost_typeであるinput要素の値valueがpostであったときということです。

<input type="hidden" name="post_type" id="post_type" value="post">

変数を宣言する

全体で利用する変数を宣言します。

my-post-validation.js

    //input要素に紐付けられているlabel要素
    const label = document.querySelectorAll('.label');

    //エラーメッセージ
    const msg = document.querySelectorAll('.msg');

        //投稿ページのタイトル
        const title = document.getElementById('title');

        //titleの親要素
        const titleWrap = document.getElementById('titlewrap');

        //投稿ページのタイトルへのエラーメッセージ
        const msgTitle = document.createElement('span');
        msgTitle.className = 'msg msg-title text-danger d-none';
        msgTitle.innerHTML = '商品名が入力されていません。';
        titleWrap.appendChild(msgTitle);

        //入力フィールド
        const field = Array.prototype.slice.call(document.querySelectorAll('.field'));

        //公開・更新ボタン
        const publish = document.getElementById('publish');

        //商品の説明の入力フィールド
        const description = document.querySelector('.field-description');

        //商品の説明の文字数
        const num = document.getElementById('num');

        //サムネイルを持つ親要素
        const images = document.getElementById('images');

        //画像をアップロードしていないときに表示するエラー
        const msgImage3 = document.querySelector('.msg-image-3');

変数についてはコメントの通りですが、投稿ページのタイトルへのエラーメッセージは宣言だけでなく、DocumentのcreateElementメソッドで要素を生成してそれにクラスの追加と文字列の挿入を行い、親要素titleWrapに挿入しています。

商品の説明の文字数を判定する

投稿ページの表示後にcountLength関数を実行して、商品の説明の入力文字数が140字より多ければエラーメッセージを表示し、140字以下であればエラーメッセージを非表示にします。

my-post-validation.js

    countLength();

        function countLength() {
            let count = description.value.length;
            num.innerHTML = count;
            if(count > 140) {
                num.classList.add('text-danger');
            }else{
                num.classList.remove('text-danger');
            }
        }


また、countLength関数は商品の説明が入力される度に実行されるようにします。

my-post-validation.js

description.addEventListener('keyup', (e) => countLength());

公開・更新するときにバリデーションを行う

公開・更新ボタンを押したときにバリデーションが行われるようにします。

my-post-validation.js

    $('#post').submit(function(e) {
            const msgImage = document.querySelectorAll('.msg-image');
            let count = 0;          
            const errorItems = [];

#postのidを持つフォームをsubmit、つまり投稿を公開または更新したときにfunction(e) {}が実行されます。

function(e) {}ではバリデーションが行われます。

始めに変数・配列を宣言します。msgImageは画像の種類にに関するエラーメッセージであり、countはエラーが発生した項目の数、errorItemsはエラーとなった項目のinput要素を格納する配列です。

これらの変数・配列を$('#post').submit(function(e) {});内に宣言することで、送信される度にmsgImageは現在の内容を取得し、counterrorItemsはリセットします。

入力必須の判定

すべての項目を入力必須とするため、入力されているかどうかの判定を行います。画像の判定は別に行います。

始めにタイトルを判定します。

my-post-validation.js

    if(title.value === '') {

                msgTitle.classList.remove('d-none');
                errorItems.push(title);
                count++;
            }else{
                msgTitle.classList.add('d-none');
            }

タイトルに何も入力されていなければmsgTitleから.d-noneクラスを削除して「商品名が入力されていません」というエラーメッセージを表示し、エラーとなったinput要素titleerrorItemsに追加します。また、エラーとなったのでcountを一つ増やします。

逆に入力されていれば.d-noneクラスを追加してエラーメッセージを非表示にします。

次に価格・在庫数・商品の説明の判定を行います。

my-post-validation.js

    for(let i = 0; i < field.length; i++) {
                msg[i].innerHTML = '';
                if(field[i].value == '') {
                    msg[i].innerHTML = `${label[i].textContent}が未入力です。`;
                    errorItems.push(field[i]);
                    count++;
                }

それまで表示されていたエラーメッセージをリセットしてからタイトルと同様に判定します。

尚、エラーメッセージの文字列はlabel要素の文字列を利用して作成します。

価格と在庫数は半角数字のみ

入力した価格と在庫数が半角数字でなければ、価格のフォーム下に「半角数字で入力してください。」とエラーメッセージを表示します。

my-post-validation.js

        if(field[i].classList.contains('field-price') || field[i].classList.contains('field-stock')) {
                    if(!field[i].value == '' && !/^[0-9]+$/.test(field[i].value)) {
                        msg[i].innerHTML = `半角数字で入力してください。`;
                        errorItems.push(field[i]);
                        count++;
                    }
                }

半角数字であるかどうかの判定は正規表現で行います。/^[0-9]+$/.test(field[i].value)の部分が正規表現です。

商品の説明は140字以内

入力した商品の説明の文字数が140字を超えていれば、商品の説明のフォーム下に「文字数が140字を超えています。」とエラーメッセージを表示します。

my-post-validation.js

        if(field[i].classList.contains('field-description')) {
                    if(field[i].value.length > 140) {
                        msg[i].innerHTML = `文字数が140字を超えています。`;
                        errorItems.push(field[i]);
                        count++;
                    }
                }

画像は選択必須&JPEGのみ&5枚まで

画像が1枚も選択されていなければエラーメッセージを表示します。

my-post-validation.js

    if(images.childElementCount === 0) {//images.hasChildNodes()では無理だった
                count++;
                msgImage3.innerHTML = '画像が選択されていません。';
            }

images.childElementCountimagesの子要素の数です。この数が0であれば1枚も選択されていないことになります。

選択した画像がJPEGではない、選択した画像が5枚を超えたときのエラーメッセージの表示はmy-uploader.jsで(画像の選択後に既に表示されているので)行っているため、ここではcountを一つ増やすのみとします。

my-post-validation.js

    for(let i = 0; i < msgImage.length; i++) {
                if(msgImage[i].innerHTML !== '') {
                    count++;
                }
            }

エラーが起こった最初の入力フィールドをフォーカスする

一度にすべてのエラーが起こった入力フィールドをフォーカスすることはできないため、エラー起こった入力フィールドのうち、最初の入力フィールドのみをフォーカスします。

my-post-validation.js

    if(typeof errorItems[0] !== 'undefined') {
                errorItems[0].focus();
            }

この処理のためにerrorItemsにinput要素を追加していたのです。

いきなりerrorItems[0].focus();と書いてしまうと、errorItems[0]undefinedであったときに構文エラーが発生し、処理がその場で終わってしまうため、例え入力エラーが起こっていてもそのまま送信されてしまいます。

公開または更新を無効にする

各入力フォームでエラーが起こっている間はボタンを押しても公開または更新を無効にします。

my-post-validation.js

    if(count > 0) {
                return false;
            }

countが0より大きい間、つまりエラーメッセージが表示されている間はreturn false;でsubmitイベントをキャンセルすることで、ボタンを押しても何も起こらないようにしています。

反対にエラーが無ければそのまま送信を完了して公開または更新します。

これでmy-post-validation.jsは完成です。

最後にadmin_scripts関数にmy-post-validation.jsを読み込むコードを追加します。

functions.php

function admin_scripts($hook_suffix) {
    if($hook_suffix == "post.php" || $hook_suffix == "post-new.php") {
        wp_enqueue_media();
        wp_enqueue_script(
            'my-media-uploader',
            get_bloginfo('stylesheet_directory').'/js/my-uploader.js',
            array('jquery'),
            filemtime(dirname(__FILE__).'/js/my-uploader.js'),
            false
        );
        echo <<< EOS
            <style type="text/css">
            .responsive-image {
                height:0;
                overflow:hidden;
                padding-top: 100%;
                position:relative;
                width:100%;
            }   
            .responsive-image img {
                position:absolute;
                top:0;
                left:0;
            }
            </style>
EOS;
        wp_enqueue_script(
            'my-post-validation',
            get_bloginfo('stylesheet_directory').'/js/my-post-validation.js',
            array('jquery'),
            filemtime(dirname(__FILE__).'/js/my-post-validation.js'),
            false
        );
    }
}

これでadmin_scripts関数は完成です。

入力した内容を保存する

最後に更新または公開したときに入力した内容に誤りがなければ入力した内容をデータベースに保存します。

この処理はsave_custom_postdata関数で行います。

functions.php

function save_custom_postdata($post_id) {
        if(!empty($_POST['my_nonce']) && wp_verify_nonce($_POST['my_nonce'], wp_create_nonce(__FILE__))) {
            $productName = $_POST['productName'] ? esc_attr($_POST['productName']) : null;
            $productName_ex = esc_attr(get_post_meta($post_id, 'name', true));
            if($productName) {
                update_post_meta($post_id, 'name', $productName);
            }else{
                delete_post_meta($post_id, 'name', $productName_ex);
            }

            $productPrice = $_POST['productPrice'] ? esc_attr($_POST['productPrice']) : null;
            $productPrice_ex = esc_attr(get_post_meta($post_id, 'price', true));
            if($productPrice) {
                update_post_meta($post_id, 'price', $productPrice);
            }else{
                delete_post_meta($post_id, 'price', $productPrice_ex);
            }

            $productStock = $_POST['productStock'] ? esc_attr($_POST['productStock']) : null;
            $productStock_ex = esc_attr(get_post_meta($post_id, 'stock', true));
            if($productStock) {
                update_post_meta($post_id, 'stock', $productStock);
            }else{
                delete_post_meta($post_id, 'stock', $productStock_ex);
            }

            $productDescription = $_POST['productDescription'] ? esc_attr($_POST['productDescription']) : null;
            $productDescription_ex = esc_attr(get_post_meta($post_id, 'description', true));
            if($productDescription) {
                update_post_meta($post_id, 'description', $productDescription);
            }else{
                delete_post_meta($post_id, 'description', $productDescription_ex);
            }

            $productImage = $_POST['productImage'] ? $_POST['productImage'] : null;
            $productImage_ex = get_post_meta($post_id, 'image', true);
            if($productImage) {
                update_post_meta($post_id, 'image', $productImage);
            }
        }
    }

wp_verify_nonce関数で送られてきたnonceを検証します。現在のnonceまたは1つ前のnonceと同じであれば、update_post_meta関数で入力した値をデータベースに上書きします。

それぞれupdate_post_meta関数で送られてきた入力内容をデータベースに上書きします。

最後にsave_postフックにsave_custom_postdata関数を登録して、更新または公開ボタンが押されたときにsave_custom_postdata関数が実行されるようにします。

functions.php

function __construct() {
    add_action('admin_menu', array($this, 'add_custom_inputbox'));
    add_action('admin_enqueue_scripts', array($this, 'admin_scripts'));
    add_action('save_post', array($this, 'save_custom_postdata'));
}

これでコンストラクタ及びCustomFieldNameクラスの完成です。

最後にCustomFieldNameクラスをインスタンス化すれば、投稿画面に以下のようなメタボックスが追加されて商品情報を投稿できるようになります。

投稿一覧に商品情報の各項目とその値を追加する。

現状では投稿一覧の項目は商品名とカテゴリーのみ表示されており、いくらなんでも情報量が少ないです。なので価格・在庫数・商品の説明・サムネイルを項目に追加します。

始めに以下のコードをfunctions.phpに追加します。

functions.php

function manage_posts_columns($columns) {
    $categories_column = $columns['categories'];
    $date_column = $columns['date'];

    unset($columns['title']);
    unset($columns['categories']);
    unset($columns['date']);

    $columns['image'] = '';
    $columns['title'] = '商品名';
    $columns['price'] = '価格';
    $columns['stock'] = '在庫数';
    $columns['description'] = '商品の説明';

    $columns['categories'] = $categories_column;
    $columns['date'] = $date_column;
var_dump($columns);
    return $columns;
}

add_filter('manage_posts_columns', 'manage_posts_columns');

manage_posts_columns関数は投稿一覧に独自の項目を追加する関数です。

配列$columnsに新たに自身で決めたキーと値のセットを追加して$columnsを返すことで、独自の項目を追加することができます。

しかし、ただ追加するだけでは元々あったタイトル、カテゴリー、日付の項目の後ろに追加されていくことになり、不自然な順番になってしまいます。

なので、以下のようにしてサムネイル・商品名・価格・在庫数・商品の説明・カテゴリー・日付の順に変更します。

  1. 元々あったタイトル、カテゴリー、日付の項目のうち、タイトルを除く項目を変数に保存する。タイトルは項目名を変更するため保存しない。
  2. unset関数でタイトル、カテゴリー、日付の項目を削除する。
  3. サムネイル・商品名・価格・在庫数・商品の説明・カテゴリー・日付の順に項目を追加する。このとき、カテゴリーと日付は、1.で保存した項目を追加する。

最後にadd_filter関数でこの関数をmanage_posts_columnsフックに登録することで、投稿一覧に項目が追加されます。

次に以下のコードをfunctions.phpに追加して項目の値を出力します。

functions.php

function add_posts_columns($column_name, $post_id) {
    if($column_name == 'image') {
        $image = get_post_meta($post_id, 'image', true)[0];
        echo '<div class="embed-responsive embed-responsive-16by9">'.wp_get_attachment_image(esc_attr($image), 'full', false, array('class' => 'embed-responsive-item')).'</div>';
    }
    if($column_name == 'title') {
        $name = get_post_meta($post_id, 'pname', true);
        echo esc_attr($name);
    }
    if($column_name == 'price') {
        $price = get_post_meta($post_id, 'price', true);
        echo esc_attr($price);
    }
    if($column_name == 'stock') {
        $stock = get_post_meta($post_id, 'stock', true);
        echo esc_attr($stock);
    }
    if($column_name == 'description') {
        $description = get_post_meta($post_id, 'description', true);
        echo esc_attr($description);
    }
}

add_action('manage_posts_custom_column', 'add_posts_columns', 10, 2);

add_posts_columns関数は先程追加した項目の値を出力する関数です。

get_post_meta関数で項目の値を取得し、echoで出力しています。

最後にadd_action関数でmanage_posts_custom_columnフックにadd_posts_columns関数を登録することで投稿一覧に項目の値が出力されます。

これで、以下のように投稿一覧に商品情報の各項目とその値が追加されました。

クイック編集から商品情報を変更できるようにする

デフォルトでは先程独自で追加した商品情報の入力フィールドはクイック編集画面には追加されないため、クイック編集で商品情報を変更することができません。

なので、クイック編集画面に投稿画面で追加したメタボックス内の入力フィールドを追加して、クイック編集からでも商品情報を変更できるようにします。

始めにfunctions.phpにクイック編集画面に入力フィールドを追加するコードを記述します。

functions.php

function display_my_custom_quickedit($column_name, $post_type) {
    static $print_nonce = TRUE;
    if($print_nonce) {
        $print_nonce = FALSE;
        wp_nonce_field('quick_edit_action', $post_type . '_edit_nonce');
    }
    ?>
    <fieldset class="inline-edit-col-right inline-custom-meta">
        <div class="inline-edit-col column-<?php echo $column_name ?>">
            <label class="inline-edit-group">
                <?php
                    switch($column_name) {
                        case 'price':
                ?>
                        <span class="title">価格</span>
                        <input type="number" name="price" required />
                <?php
                            break;
                        case 'stock':
                ?>
                        <span class="title">在庫数</span>
                        <input type="number" name="stock" required />
                <?php
                            break;
                        case 'description';
                ?>
                        <span class="title">商品の説明</span>
                        <textarea name="description" /></textarea>
                <?php
                            break;
                    }
                ?>
            </label>
    </fieldset>
    <?php
}
add_action('quick_edit_custom_box', 'display_my_custom_quickedit', 10, 2);

次に、追加した入力フィールドに投稿画面で入力した値を表示したいところですが、quick_edit_custom_box関数には引数としてポストIDが渡されないため、get_post_meta関数で投稿画面で入力した値を取得することができません。このため、この関数内では実現不可能です。

なので、この処理はJavaScriptで行います。

始めに、admin_edit.jsを作成します。

admin_edit.js

(function($) {
    const $wp_inline_edit = inlineEditPost.edit;

    inlineEditPost.edit = function(id) {
        $wp_inline_edit.apply(this, arguments);

        let $post_id = 0;
        if(typeof(id) == 'object')
            $post_id = parseInt(this.getId(id));
        if($post_id > 0) {
            const $edit_row = $('#edit-' + $post_id);
            const $post_row = $('#post-' + $post_id);

            const $price = $('.column-price', $post_row).html();
            $(':input[name="price"]', $edit_row).val($price);

            const $stock = $('.column-stock', $post_row).html();
            $(':input[name="stock"]', $edit_row).val($stock);

            const $description = $('.column-description', $post_row).html();
            $(':input[name="description"]', $edit_row).val($description);
        }
    }
})(jQuery);

次に、admin_edit.jsを管理ページのフッターに読み込むコードをfunctions.phpに記述します。

functions.php

function my_admin_edit_foot() {
    global $post_type;
    $slug = 'post';

    if($post_type == $slug) {
        echo '<script type="text/javascript" src="'.get_stylesheet_directory_uri().'/admin/js/admin_edit.js'.'"></script>';
    }
}
add_action('admin_footer-edit.php', 'my_admin_edit_foot');

これで、クイック編集画面の入力フィールドに投稿画面で入力した値が表示されます。

最後に、入力フィールドに入力した値を保存します。

functions.phpに以下のコードを記述します。

functions.php

function save_custom_meta($post_id) {
    $slug = 'post';

    if($slug !== get_post_type($post_id)) {
        return;
    }

    if(!current_user_can('edit_post', $post_id)) {
        return;
    }

    $_POST += array("{$slug}_edit_nonce" => '');

    if(!wp_verify_nonce($_POST["{$slug}_edit_nonce"], 'quick_edit_action')) {
        return;
    }

    if(isset($_REQUEST['price'])) {
        update_post_meta($post_id, 'price', $_REQUEST['price']);
    }

    if(isset($_REQUEST['stock'])) {
        update_post_meta($post_id, 'stock', $_REQUEST['stock']);
    }

    if(isset($_REQUEST['description'])) {
        update_post_meta($post_id, 'description', $_REQUEST['description']);
    }
}

add_action('save_post', 'save_custom_meta');

これで、クイック編集画面の入力フィールドに入力した値が更新ボタンを押すことで保存されます。

投稿した商品情報を商品一覧ページに出力する

商品を投稿する機能の実装が完成したので、投稿した商品情報を商品一覧ページに出力します。

content-list.phpを作成します。このテンプレートはindex.phpのコンテンツ部分です。※全く同じ構造にする必要はありません。

content-list.php

<div class="col-sm-6 col-md-4 col-lg-3 mb-4">
    <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
        <?php
            $cfName = get_post_meta($post->ID, 'name', true);
            $cfPrice = get_post_meta($post->ID, 'price', true);
            $cfStock = get_post_meta($post->ID, 'stock', true);
            $cfImage = get_post_meta($post->ID, 'image', true);
        ?>
        <div class="card">
            <div class="bg-light embed-responsive embed-responsive-16by9">
            <?php if($cfImage): ?>
                <?php echo wp_get_attachment_image(esc_html($cfImage[0]), 'full', false, array('class' => 'embed-responsive-item')); ?>
            <?php else: ?>
                No image
            <?php endif; ?>
            </div>
            <div class="card-body">
                <?php the_title( sprintf( '<h5 class="card-title"><a href="%s" rel="bookmark">', esc_url( get_permalink() ) ), '</a></h5>' ); ?>
                <h6 class="card-subtitle mb-2 text-muted">¥<?php echo number_format(esc_html($cfPrice)) ?></h6>
                <p class="card-text">
                <?php 
                    if($cfStock <= 0){
                        echo '<strong class="text-danger">在庫なし</strong>';   
                    }else if($cfStock < 10) {
                        echo '<strong class="text-warning">残り'.esc_html($cfStock).'点</strong>';
                    }else {
                        echo '<strong class="text-success">在庫あり</strong>';
                    }
                ?>
                </p>
            </div>
        </div>
    </article><!-- #post-## -->
</div>

get_post_meta関数で入力した各商品情報を取得して、それらをechoで出力しています。

画像の出力は、get_post_meta関数で取得した値は画像情報を持つIDであるため、echoでそのまま出力するだけでは出力されません。

そのため、wp_get_attachment_image関数で画像情報を持つIDを基にimg要素を生成してから出力する必要があります。

在庫の出力は、在庫数が0だったら「在庫なし」と出力し、10より少なければ「残り〇点」、それ以外であれば「在庫あり」と出力します。

また、何も投稿されていない場合に、その旨のメッセージを表示するページのテンプレートcontent-none.phpを作成します。

content-none.php

 <header class="page-header">
        <h1 class="page-title"><?php _e( 'Nothing Found'); ?></h1>
    </header><!-- .page-header -->
    <div class="page-content">
        <?php if ( is_home() && current_user_can( 'publish_posts' ) ) : ?>
            <p><?php printf( __( 'Ready to publish your first post? <a href="%1$s">Get started here</a>.' ), esc_url( admin_url( 'post-new.php' ) ) ); ?></p>
        <?php elseif ( is_search() ) : ?>
            <p><?php _e( 'Sorry, but nothing matched your search terms. Please try again with some different keywords.' ); ?></p>
            <?php get_search_form(); ?>
        <?php else : ?>
            <p><?php _e( 'It seems we can’t find what you’re looking for. Perhaps searching can help.' ); ?></p>
            <?php get_search_form(); ?>
        <?php endif; ?>
    </div><!-- .page-content -->
</section><!-- .no-results -->

そしてindex.phpにget_template_part関数でcontent-list.phpとcontent-none.phpをインクルードします。※content-list.phpやcontent-none.php同様、全く同じ構造にする必要はありません。

index.php

<?php
get_header(); ?>

    <div id="primary" class="content-area">
        <main id="main" class="site-main" role="main">
        <?php if ( have_posts() ) : ?>
            <div class="row site-main-inner mb-4">
            <?php
            while ( have_posts() ) : the_post();
                get_template_part( 'content-list', get_post_format() );
            endwhile;
            ?>
            </div>
        <?php else :
            get_template_part( 'content', 'none' );
        endif;
        ?>
        </main><!-- .site-main -->
    </div><!-- .content-area -->
<?php get_footer(); ?>

content-list.phpは必ずループ内でインクルードします。

コードでへheader.phpとfooter.phpは既に用意されているものとします。

これで、以下のように商品一覧ページに商品情報がそれぞれ出力されます。

入力した内容を商品詳細ページに出力する

商品一覧ページと同様に、投稿した商品の情報を商品詳細ページに出力します。

始めにcontent.phpを以下のようにします。このテンプレートはsingle.phpのコンテンツ部分です。

content.php

<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
    <div class="entry-content mb-5">
    <?php
        $price = get_post_meta($post->ID, 'price', true);
        $stock = get_post_meta($post->ID, 'stock', true);
        $description = get_post_meta($post->ID, 'description', true);
        $images = get_post_meta($post->ID, 'image', true);
    ?>
        <div class="row">
            <div class="col-lg-7">
                <div class="main-image embed-responsive embed-responsive-16by9 bg-light mb-3">
                    <?php echo wp_get_attachment_image($images[0], 'full', false, array('class' => 'embed-responsive-item')); ?>
                </div>
            </div>
            <div class="col-lg-5">
                <?php the_title( '<h1 class="product-name mt-0 mb-3">', '</h1>' ); ?>
                <dl class="product-price d-flex font-weight-bold mb-3">
                    <dt class="text-danger">¥</dt>
                    <dd class="text-danger">
                        <?php echo number_format(esc_html($price)); ?>
                    </dd>
                </dl>
                <div class="product-stock mb-3">
                <?php if($stock == 0): ?>
                    <strong class="text-danger">在庫なし</strong>
                <?php elseif($stock < 10): ?>
                    <strong class="text-warning">残り<?php echo esc_html($stock) ?>点</strong>
                <?php else: ?>
                    <strong class="text-success">在庫あり</strong>
                <?php endif; ?>
                </div>
                <div class="product-description">
                <?php echo esc_html($description) ?>
                </div>
            </div>
        </div>
    </div><!-- .entry-content -->
</article><!-- #post-## -->

やっていることは先程のcontent-list.phpと同じです。

そしてget_template_part関数でsingle.phpにcontent.phpをインクルードします。

single.php

<?php get_header(); ?>
    <div id="primary" class="content-area">
        <main id="main" class="site-main" role="main">
        <?php while ( have_posts() ) : the_post();
            get_template_part( 'content', get_post_format() );
        endwhile;
        ?>  
        </main><!-- .site-main -->
    </div><!-- .content-area -->
<?php get_footer(); ?>

こちらも必ずループ内にインクルードします。

これで、以下のように商品詳細ページに商品情報がそれぞれ出力されます。

以上で商品の投稿機能を実装する方法を終わります。

参考文献