【EC-CUBE 4】管理画面 / 商品管理のカスタマイズ(4) ~ ソート機能 ~

以下カスタマイズの続き・ソート(並び替え)機能までを含んだ実装方法(コード)を紹介します。

カスタマイズ完了後の管理画面

【動作環境】
EC CUBEのバージョン:4.2.2
サーバー:Xserver

開発前にデバッグモードの設定をお薦めします

デバッグモードを設定しておくと、エラーが起きたときに詳細情報が表示されるようになります。
エラー箇所を探しやすくなるので、開発前に設定しておくのをオススメします。
デバッグモードの設定方法については 以下記事 で解説しています。

カスタマイズ後は、デバッグモードの解除を忘れないように。

目次

カスタマイズの流れ

  • MakerEntityの修正~テーブル拡張(プロパティsort_noの追加)
  • MakerRepositoryの修正(sort_no関連の処理を追加)
  • MakerControllerの修正(sort_noを変更して保存するmoveSortNo()メソッドの追加など)
  • maker.twigの修正(上下矢印の追加、表示順を入れ替えるJavaScriptの追加、など)
STEP

MakerEntityを修正し、dtb_makerテーブルを拡張する

表示順を格納しておくプロパティsort_no を「dtb_maker」に追加します。
テーブルを新規作成するときと同様に、以下修正後のエンティティをアップしてからSSHにてコマンドを入力し、テーブルを拡張します。

<?php

namespace Customize\Entity;

use Eccube\Entity\AbstractEntity;
use Doctrine\ORM\Mapping as ORM;

/**
 * Maker
 *
 * @ORM\Table(name="dtb_maker")
 * @ORM\InheritanceType("SINGLE_TABLE")
 * @ORM\DiscriminatorColumn(name="discriminator_type", type="string", length=255)
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Entity(repositoryClass="Customize\Repository\MakerRepository")
 */
class Maker extends AbstractEntity
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer", options={"unsigned":true})
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     *
     * メーカーの一意識別ID(自動連番)です。
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255)
     *
     * メーカーの名前です。
     */
    private $name;

    /**
     * @var string|null
     *
     * @ORM\Column(name="code", type="string", length=8, nullable=true)
     *
     * メーカーのコードです。null(未入力)も許容されます。
     */
    private $code;

    /**
     * @var \DateTime
     *
     * @ORM\Column(name="create_date", type="datetimetz")
     *
     * メーカー情報の作成日時です。
     */
    private $create_date;

    /**
     * @var \DateTime
     *
     * @ORM\Column(name="update_date", type="datetimetz")
     *
     * メーカー情報の最終更新日時です。
     */
    private $update_date;

    /**
     * @var int
     *
     * @ORM\Column(name="sort_no", type="integer", options={"unsigned":true})
     * 
     * 並び順です。
     */
    private $sort_no;

    /**
     * Get id.
     *
     * @return int
     * 
     * メーカーのIDを取得します。
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set name.
     *
     * @param string $name
     *
     * @return Maker
     * 
     * メーカーの名前を設定します。
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

    /**
     * Get name.
     *
     * @return string
     * 
     * メーカーの名前を取得します。
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Set code.
     *
     * @param string|null $code
     *
     * @return Maker
     * 
     * メーカーのコードを設定します。null(未入力)も許容されます。
     */
    public function setCode($code = null)
    {
        $this->code = $code;

        return $this;
    }

    /**
     * Get code.
     *
     * @return string|null
     * 
     * メーカーのコードを取得します。
     */
    public function getCode()
    {
        return $this->code;
    }

    /**
     * Set createDate.
     *
     * @param \DateTime $createDate
     *
     * @return Maker
     * 
     * メーカー情報の作成日時を設定します。
     */
    public function setCreateDate($createDate)
    {
        $this->create_date = $createDate;

        return $this;
    }

    /**
     * Get createDate.
     *
     * @return \DateTime
     * 
     * メーカー情報の作成日時を取得します。
     */
    public function getCreateDate()
    {
        return $this->create_date;
    }

    /**
     * Set updateDate.
     *
     * @param \DateTime $updateDate
     *
     * @return Maker
     * 
     * メーカー情報の最終更新日時を設定します。
     */
    public function setUpdateDate($updateDate)
    {
        $this->update_date = $updateDate;

        return $this;
    }

    /**
     * Get updateDate.
     *
     * @return \DateTime
     * 
     * メーカー情報の最終更新日時を取得します。
     */
    public function getUpdateDate()
    {
        return $this->update_date;
    }

    /**
     * Set sortNo.
     *
     * @param int $sortNo
     *
     * @return Maker
     */
    public function setSortNo($sortNo)
    {
        $this->sort_no = $sortNo;

        return $this;
    }

    /**
     * Get sortNo.
     *
     * @return int
     */
    public function getSortNo()
    {
        return $this->sort_no;
    }
}

以降のステップで、メーカーはsort_noの大きい順に表示されるようになります。ただし、すでに登録済のメーカーに対してはsort_noが設定されていない または 0になっていると思いますので、1から連番となるよう数値を入力しておきます。(今後、管理画面から新規で追加されるメーカーには自動で数値が割り振られるので、データベースへのアクセスは不要です。)

STEP

MakerRepositoryを修正し、sort_no関連の処理を追加する

  • メーカーの取得順をsort_noの大きい順(降順=DESC)に
  • メーカーを保存するとき、sort_noが自動でセットされるように
  • メーカーを削除するとき、他の保存済メーカーのsort_noを詰めるように

各メソッドを修正します。ちなみに参考にしたファイルはClassNameRepository(src/Eccube/Repository)です。

<?php

/*
 * This file is part of EC-CUBE
 *
 * Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
 *
 * http://www.ec-cube.co.jp/
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Customize\Repository;

use Doctrine\Persistence\ManagerRegistry as RegistryInterface;
use Customize\Entity\Maker;
use Eccube\Repository\AbstractRepository;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;

/**
 * MakerRepository
 */
class MakerRepository extends AbstractRepository
{
    /**
     * MakerRepository constructor.
     *
     * @param RegistryInterface $registry
     */
    public function __construct(RegistryInterface $registry)
    {
        parent::__construct($registry, Maker::class);
    }

    /**
     * メーカー一覧を取得する.
     *
     * @return array
     */
    public function getList()
    {
        $qb = $this->createQueryBuilder('cn')
            ->orderBy('cn.sort_no', 'DESC');
        $Makers = $qb->getQuery()
            ->getResult();

        return $Makers;
    }

    /**
     * メーカーを保存する.
     *
     * @param Maker $maker
     */
    public function save($maker)
    {
        if (!$maker->getId()) {
          $sortNo = $this->createQueryBuilder('cn')
              ->select('COALESCE(MAX(cn.sort_no), 0)')
              ->getQuery()
              ->getSingleScalarResult();
          $maker->setSortNo($sortNo + 1);
        }

        $em = $this->getEntityManager();
        $em->persist($maker);
        $em->flush();
    }

    /**
     * メーカーを削除する.
     *
     * @param Maker $maker
     * 
     * @throws ForeignKeyConstraintViolationException 外部キー制約違反の場合
     * @throws DriverException SQLiteの場合, 外部キー制約違反が発生すると, DriverExceptionをthrowします.
     */
    public function delete($maker)
    {
        $sortNo = $maker->getSortNo();
        $this->createQueryBuilder('cn')
            ->update()
            ->set('cn.sort_no', 'cn.sort_no - 1')
            ->where('cn.sort_no > :sort_no')
            ->setParameter('sort_no', $sortNo)
            ->getQuery()
            ->execute();
            
        $em = $this->getEntityManager();
        $em->remove($maker);
        $em->flush();
    }
}
STEP

MakerControllerを修正し、メーカーの取得方法とmoveSortNo()メソッドを追加

  • 保存済メーカーの取得は、STEP 2で新たに追加したメソッドgetList()を使い、sort_noが降順で取得されるようにします。
    • これにより、表示されるメーカーリストがsort_noの大きい順になります。
  • リストを並び替えたときの処理用のメソッドmoveSortNo()を追加します。

ちなみに参考にしたファイルはClassNameController(src/Eccube/Controller/Admin/Product)です。

<?php

/*
 * This file is part of EC-CUBE
 *
 * Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
 *
 * http://www.ec-cube.co.jp/
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Customize\Controller\Admin\Product;

use Eccube\Controller\AbstractController;
use Customize\Entity\Maker;
use Customize\Form\Type\Admin\MakerType;
use Customize\Repository\MakerRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;

class MakerController extends AbstractController
{
    /**
     * @var MakerRepository
     */
    protected $makerRepository;

    /**
     * MakerController constructor.
     *
     * @param MakerRepository $makerRepository
     */
    public function __construct(MakerRepository $makerRepository)
    {
        $this->makerRepository = $makerRepository;
    }

    /**
     * @Route("/%eccube_admin_route%/product/maker", name="admin_product_maker")
     * @Template("@admin/Product/maker.twig")
     */
    public function index(Request $request)
    {
        $makers = $this->makerRepository->getList();

        $newMaker = new Maker();

        $builder = $this->formFactory
            ->createBuilder(MakerType::class, $newMaker);

        $form = $builder->getForm();

        /**
         * 編集用フォーム
         */
        $forms = [];
        foreach ($makers as $maker) {
            $id = $maker->getId();
            $forms[$id] = $this->formFactory->createNamed('maker_'.$id, MakerType::class, $maker);
        }

        /**
         * 新規登録処理
         */
        if ($request->getMethod() === 'POST') {
            $form->handleRequest($request);
            if ($form->isSubmitted() && $form->isValid()) {
                $this->makerRepository->save($newMaker);
                $this->addSuccess('admin.common.save_complete', 'admin');
                return $this->redirectToRoute('admin_product_maker');
            }
        }

        /*
         * 編集処理
         */
        foreach ($forms as $editForm) {
            $editForm->handleRequest($request);
            if ($editForm->isSubmitted() && $editForm->isValid()) {
                $this->makerRepository->save($editForm->getData());
                $this->addSuccess('admin.common.save_complete', 'admin');
                return $this->redirectToRoute('admin_product_maker');
            }
        }

        $formViews = [];
        foreach ($forms as $key => $value) {
            $formViews[$key] = $value->createView();
        }

        return [
            'form' => $form->createView(),
            'makers' => $makers,
            'newMaker' => $newMaker,
            'forms' => $formViews,
        ];
    }

    /**
     * @Route("/%eccube_admin_route%/product/maker/{id}/delete", requirements={"id" = "\d+"}, name="admin_product_maker_delete", methods={"DELETE"})
     */
    public function delete(Request $request, Maker $maker)
    {
        $this->isTokenValid();

        try {
            $this->makerRepository->delete($maker);
            $this->addSuccess('admin.common.delete_complete', 'admin');
        } catch (\Exception $e) {
            $message = trans('admin.common.delete_error_foreign_key', ['%name%' => $maker->getName()]);
            $this->addError($message, 'admin');
        }

        return $this->redirectToRoute('admin_product_maker');
    }

    /**
     * @Route("/%eccube_admin_route%/product/maker/sort_no/move", name="admin_product_maker_sort_no_move", methods={"POST"})
     */
    public function moveSortNo(Request $request)
    {
      if (!$request->isXmlHttpRequest() && $this->isTokenValid()) {
         throw new BadRequestHttpException();
      }
      
      if ($this->isTokenValid()) {
          $sortNos = $request->request->all();
          foreach ($sortNos as $makerId => $sortNo) {
              $Maker = $this->makerRepository
                  ->find($makerId);
              $Maker->setSortNo($sortNo);
              $this->entityManager->persist($Maker);
          }
          $this->entityManager->flush();

          return new Response();
      }
    }
}
STEP

maker.twigを修正し、リストの入れ替え機能を実装する

  • 順番を入れ替えられる、上下矢印のアイコンを設置します。
  • リストをドラッグ&ドロップしたとき および 上下矢印アイコンをクリックしたときの処理を、JavaScript(JQuery)で記述します。

ちなみに参考にしたファイルはclass_name.twig(src/Eccube/Resource/template/admin/Product)です。

{#
This file is part of EC-CUBE

Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.

http://www.ec-cube.co.jp/

For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
#}
{% extends '@admin/default_frame.twig' %}

{% set menus = ['product', 'maker'] %}

{% block title %}{{ 'admin.product.maker_management'|trans }}{% endblock %}
{% block sub_title %}{{ 'admin.product.product_management'|trans }}{% endblock %}

{% form_theme form '@admin/Form/bootstrap_4_horizontal_layout.html.twig' %}
{% block stylesheet %}
    <style type="text/css">
        .list-group-item:hover {
            z-index: inherit;
        }
    </style>
{% endblock stylesheet %}

{% block javascript %}
    <script>
        $(function() {
            var oldSortNos = [];
            // 画面の中のsortNo一覧を保持
            $('.sortable-item').each(function() {
                oldSortNos.push(this.dataset.sortNo);
            });
            // rsort
            oldSortNos.sort(function(a, b) {
                return a - b;
            }).reverse();

            $('.sortable-container').sortable({
                items: '> .sortable-item',
                cursor: 'move',
                update: function(e, ui) {
                    $('body').append($('<div class="modal-backdrop show"></div>'));
                    updateSortNo();
                }
            });

            var updateSortNo = function() {
                // 並び替え後にsortNoを更新
                var newSortNos = {};
                var i = 0;
                $('.sortable-item').each(function() {
                    newSortNos[this.dataset.makerId] = oldSortNos[i];
                    i++;
                });

                $.ajax({
                    url: '{{ url('admin_product_maker_sort_no_move') }}',
                    type: 'POST',
                    data: newSortNos
                }).done(function() {
                    // remove class disable
                    $('a.up.disabled').removeClass('disabled');
                    $('a.down.disabled').removeClass('disabled');
                    // First element
                    $('.sortable-item > li:nth-child(2) > div > div.col-auto.text-end > a.up').addClass('disabled');
                }).always(function() {
                    redrawDisableAllows();
                    $('.modal-backdrop').remove();
                });
            };

            // 最初と最後の↑↓を再描画
            var redrawDisableAllows = function() {
                var items = $('.sortable-item');
                items.find('a.up').removeClass('disabled');
                items.find('a.down').removeClass('disabled');
                items.first().find('a.up').addClass('disabled');
                items.last().find('a.down').addClass('disabled');
            };

            $('.sortable-item a.up').click(function(e) {
                e.preventDefault();
                var current = $(this).parents('.list-group-item');
                current.prev().before(current);
                $('body').append($('<div class="modal-backdrop show"></div>'));
                updateSortNo();
            });

            $('.sortable-item a.down').click(function(e) {
                e.preventDefault();
                var current = $(this).parents('.list-group-item');
                current.next().after(current);
                $('body').append($('<div class="modal-backdrop show"></div>'));
                updateSortNo();
            });

            // 編集
            $('.sortable-item').on('click', 'a.action-edit', function(e) {
                e.preventDefault();
                var current = $(this).parents('li');
                current.find('.mode-view').addClass('d-none');
                current.find('.mode-edit').removeClass('d-none');
            });
            // 編集キャンセル
            $('.sortable-item').on('click', 'button.action-edit-cancel', function(e) {
                location.href = '{{ url('admin_product_maker') }}';
            });

            // 編集時, エラーがあれば入力欄を表示.
            $('.sortable-item').find('.is-invalid').each(function(e) {
                var current = $(this).parents('li');
                current.find('.mode-view').addClass('d-none');
                current.find('.mode-edit').removeClass('d-none');
            });

            // 削除モーダルのhrefとmessageの変更
            $('#DeleteModal').on('shown.bs.modal', function(event) {
                var target = $(event.relatedTarget);
                // hrefの変更
                $(this).find('[data-method="delete"]').attr('href', target.data('url'));

                // messageの変更
                $(this).find('p.modal-message').text(target.data('message'));
            });
        });
    </script>

{% endblock %}

{% block main %}
    <div class="c-contentsArea__cols">
        <div class="c-contentsArea__primaryCol">
            <div class="c-primaryCol">
                <div class="card rounded border-0 mb-4">
                    <div class="card-body p-0">
                        <div class="card rounded border-0">
                            <ul class="list-group list-group-flush sortable-container">
                                <li class="list-group-item">
                                    <form role="form" class="row" name="form1" id="form1" method="post" action="{{ url('admin_product_maker') }}">
                                        <div class="col-auto align-self-center"><span>{{ 'admin.product.maker.name'|trans }}</span></div>
                                        <div class="col-3 me-2">
                                            {{ form_widget(form._token) }}
                                            {{ form_widget(form.name) }}
                                            {{ form_errors(form.name) }}
                                        </div>
                                        <div class="col-auto align-self-center" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ 'tooltip.product.code'|trans }}">
                                            <span>{{ 'admin.product.maker.code'|trans }}</span>
                                        </div>
                                        <div class="col-3">
                                            {{ form_widget(form.code) }}
                                            {{ form_errors(form.code) }}
                                        </div>
                                        <div class="col-auto">
                                            <button class="btn btn-ec-regular" type="submit">{{ 'admin.common.create__new'|trans }}</button>
                                        </div>
                                    </form>
                                </li>
                                <li class="list-group-item">
                                    <div class="row">
                                        <div class="col-auto"><strong> </strong></div>
                                        <div class="col-auto"><strong>{{ 'admin.common.id'|trans }}</strong></div>
                                        <div class="col-1"><strong>{{ 'admin.product.maker_management'|trans }}</strong></div>
                                    </div>
                                </li>
                                {% for maker in makers %}
                                    <li id="ex-class_name-{{ maker.id }}" class="list-group-item sortable-item" data-maker-id="{{ maker.id }}" data-sort-no="{{ maker.sortNo }}">
                                        <div class="row justify-content-around mode-view">
                                            <div class="col-auto d-flex align-items-center"><i class="fa fa-bars text-ec-gray"></i></div>
                                            <div class="col-auto d-flex align-items-center">{{ maker.id }}</div>
                                            <div class="col d-flex align-items-center">{{ maker.name }}</div>
                                            {# 矢印アイコンと編集アイコンと削除アイコンを設置 #}
                                            <div class="col-auto text-end">
                                                <a class="btn btn-ec-actionIcon me-2 up {% if loop.first %}disabled{% endif %}" href="" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ 'admin.common.up'|trans }}">
                                                    <i class="fa fa-arrow-up fa-lg text-secondary"></i>
                                                </a>
                                                <a class="btn btn-ec-actionIcon me-2 down {% if loop.last %}disabled{% endif %}" href="" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ 'admin.common.down'|trans }}">
                                                    <i class="fa fa-arrow-down fa-lg text-secondary"></i>
                                                </a>
                                                <a class="btn btn-ec-actionIcon me-2 action-edit" data-bs-toggle="tooltip" data-bs-placement="top" title="{{ 'admin.common.edit'|trans }}">
                                                    <i class="fa fa-pencil fa-lg text-secondary"></i>
                                                </a>
                                                <div class="d-inline-block me-2" data-bs-toggle="tooltip"
                                                     data-bs-placement="top" title="{{ 'admin.common.delete'|trans }}">
                                                    <a class="btn btn-ec-actionIcon" data-bs-toggle="modal" data-bs-target="#DeleteModal" data-url="{{ url('admin_product_maker_delete', {'id' : maker.id}) }}" data-message="{{ 'admin.common.delete_modal__message'|trans({ "%name%" : maker.name }) }}">
                                                        <i class="fa fa-close fa-lg text-secondary"></i>
                                                    </a>
                                                </div>
                                            </div>
                                        </div>
                                        {# 編集用のフォームを設置 #}
                                        <form class="row d-none mode-edit" method="post" action="{{ url('admin_product_maker') }}">
                                            {{ form_widget(forms[maker.id]._token) }}
                                            <div class="col-auto align-self-center"><span>{{ 'admin.product.maker.name'|trans }}</span></div>
                                            <div class="col-auto align-items-center">
                                                {{ form_widget(forms[maker.id].name, {'attr': {'data-origin-value': forms[maker.id].name.vars.value}}) }}
                                                {{ form_errors(forms[maker.id].name) }}
                                            </div>
                                            <div class="col-auto align-self-center"><span>{{ 'admin.product.maker.code'|trans }}</span></div>
                                            <div class="col-auto align-items-center">
                                                {{ form_widget(forms[maker.id].code, {'attr': {'data-origin-value': forms[maker.id].code.vars.value}}) }}
                                                {{ form_errors(forms[maker.id].code) }}
                                            </div>
                                            <div class="col-auto align-items-center">
                                                <button class="btn btn-ec-conversion" type="submit">{{ 'admin.common.decision'|trans }}</button>
                                            </div>
                                            <div class="col-auto align-items-center">
                                                <button class="btn btn-ec-sub action-edit-cancel" type="button">{{ 'admin.common.cancel'|trans }}</button>
                                            </div>
                                        </form>
                                    </li>
                                {% endfor %}
                            </ul>
                            <!-- 削除モーダル -->
                            <div class="modal fade" id="DeleteModal" tabindex="-1" role="dialog"
                                 aria-labelledby="DeleteModal" aria-hidden="true">
                                <div class="modal-dialog" role="document">
                                    <div class="modal-content">
                                        <div class="modal-header">
                                            <h5 class="modal-title fw-bold">
                                                {{ 'admin.common.delete_modal__title'|trans }}
                                            </h5>
                                            <button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Close">

                                            </button>
                                        </div>
                                        <div class="modal-body text-start">
                                            <p class="text-start modal-message"><!-- jsでメッセージを挿入 --></p>
                                        </div>
                                        <div class="modal-footer">
                                            <button class="btn btn-ec-sub" type="button" data-bs-dismiss="modal">
                                                {{ 'admin.common.cancel'|trans }}
                                            </button>
                                            <a class="btn btn-ec-delete" href="#" {{ csrf_token_for_anchor() }}
                                               data-method="delete" data-confirm="false">
                                                {{ 'admin.common.delete'|trans }}
                                            </a>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <p>{{ 'admin.common.drag_and_drop_description'|trans }}</p>
            </div>
        </div>
    </div>
{% endblock %}

このコードを流用する場合は、HTMLのIDやclass名なども合わせて修正しないと動作しないので注意!

STEP

キャッシュを削除

ここまでで準備完了です。
管理画面からキャッシュを削除して、商品管理メニューのメーカー管理を確認しましょう。

キャッシュの削除方法
管理画面のキャッシュ管理からキャッシュを削除できます。

冒頭の動画のような【メーカー管理】メニューが追加され、管理画面からメーカーの新規追加および追加済メーカーの「編集」「削除」「表示順入れ替え」ができるようになっているはずです!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次