2系にあった商品並び替え機能をEC-CUBE4系で実装する方法
EC-CUBE Advent Calendar 2021 17日目の記事です。
EC-CUBE2では「商品並び替え」機能が存在していましたが、EC-CUBE3系からはその機能が無くなっています。
今回は「商品並び替え」機能をEC-CUBE4系で実装する方法を説明します。EC-CUBE3系も読み替えれば実現可能と思いますが、今回は説明を割愛します。
ProductCategoryクラスの修正
ProductCategoryクラスに並び順という項目を追加します。
- src/Eccube/Entity/ProductCategory.php
<?php 〜 〜 /** * @var int * * @ORM\Column(name="sort_no", type="smallint", options={"unsigned":true, "default":0}) */ private $sort_no = 0; /** * Set sortNo. * * @param int $sortNo * * @return ProductCategory */ public function setSortNo($sortNo) { $this->sort_no = $sortNo; return $this; } /** * Get sortNo. * * @return int */ public function getSortNo() { return $this->sort_no; }
追加後、doctrineコマンドを利用してdtb_product_category
テーブルへカラムを追加します。
EC-CUBEディレクトリ直下にコマンドプロンプトやターミナルで移動します。
先ず、どのようなSQL文が実行されるかを以下のコマンドで確認します。
php bin/console doctrine:schema:update --dump-sql
実際にSQL文をするために、以下のコマンドを実行します。
php bin/console doctrine:schema:update --force
コマンド実行後、エラーが発生しなければDBにdtb_product_category
テーブルにsort_no
というカラムが作成されます。
商品並び替え管理画面の作成
次に、商品並び替え機能用の管理画面を作成します。以下のController、FormType、twigファイルを作成してください。
- src/Eccube/Controller/Admin/Product/ProductSortController.php
<?php namespace Eccube\Controller\Admin\Product; use Eccube\Controller\AbstractController; use Eccube\Entity\Category; use Eccube\Entity\ProductCategory; use Eccube\Form\Type\Admin\ProductCategoryType; use Eccube\Repository\CategoryRepository; use Eccube\Repository\ProductCategoryRepository; use Eccube\Util\CacheUtil; 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 ProductSortController extends AbstractController { /** * @var ProductCategoryRepository */ protected $productCategoryRepository; /** * @var CategoryRepository */ protected $categoryRepository; /** * ProductSortController constructor. * * @param ProductCategoryRepository $productCategoryRepository * @param CategoryRepository $categoryRepository */ public function __construct(ProductCategoryRepository $productCategoryRepository, CategoryRepository $categoryRepository) { $this->productCategoryRepository = $productCategoryRepository; $this->categoryRepository = $categoryRepository; } /** * @Route("/%eccube_admin_route%/product/sort", name="admin_product_sort") * @Route("/%eccube_admin_route%/product/sort/{parent_id}", requirements={"parent_id" = "\d+"}, name="admin_product_sort_show") * @Route("/%eccube_admin_route%/product/sort/{id}/edit", requirements={"id" = "\d+"}, name="admin_product_sort_edit") * @Template("@admin/Product/product_sort.twig") */ public function index(Request $request, $parent_id = null, $id = null, CacheUtil $cacheUtil) { if ($parent_id) { /** @var Category $Parent */ $Parent = $this->categoryRepository->find($parent_id); if (!$Parent) { throw new NotFoundHttpException(); } } else { $Parent = null; } if ($id) { $TargetCategory = $this->categoryRepository->find($id); if (!$TargetCategory) { throw new NotFoundHttpException(); } $Parent = $TargetCategory->getParent(); } else { $TargetCategory = new Category(); $TargetCategory->setParent($Parent); if ($Parent) { $TargetCategory->setHierarchy($Parent->getHierarchy() + 1); } else { $TargetCategory->setHierarchy(1); } } $ProductCategories = []; if ($Parent) { $ProductCategories = $this->productCategoryRepository->findBy(['Category' => $Parent], ['sort_no' => 'DESC']); } // ツリー表示のため、ルートからのカテゴリを取得 $TopCategories = $this->categoryRepository->getList(null); $builder = $this->formFactory ->createBuilder(ProductCategoryType::class); $form = $builder->getForm(); $forms = []; /** @var ProductCategory $ProductCategory */ foreach ($ProductCategories as $ProductCategory) { $forms[$ProductCategory->getProductId()] = $this->formFactory ->createNamed('product_category_'.$ProductCategory->getProductId(), ProductCategoryType::class, $ProductCategory); } if ($request->getMethod() === 'POST') { $form->handleRequest($request); if ($form->isValid()) { if ($this->eccubeConfig['eccube_category_nest_level'] < $TargetCategory->getHierarchy()) { throw new BadRequestHttpException(); } log_info('カテゴリ登録開始', [$id]); $this->productCategoryRepository->save($TargetCategory); $this->entityManager->flush(); log_info('カテゴリ登録完了', [$id]); $this->addSuccess('admin.common.save_complete', 'admin'); $cacheUtil->clearDoctrineCache(); if ($Parent) { return $this->redirectToRoute('admin_product_sort_show', ['parent_id' => $Parent->getId()]); } else { return $this->redirectToRoute('admin_product_sort'); } } foreach ($forms as $editForm) { $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { $this->productCategoryRepository->save($editForm->getData()); $this->entityManager->flush(); $this->addSuccess('admin.common.save_complete', 'admin'); $cacheUtil->clearDoctrineCache(); if ($Parent) { return $this->redirectToRoute('admin_product_sort_show', ['parent_id' => $Parent->getId()]); } else { return $this->redirectToRoute('admin_product_sort'); } } } } $formViews = []; foreach ($forms as $key => $value) { $formViews[$key] = $value->createView(); } $Ids = []; if ($Parent && $Parent->getParents()) { foreach ($Parent->getParents() as $item) { $Ids[] = $item['id']; } } $Ids[] = intval($parent_id); return [ 'form' => $form->createView(), 'Parent' => $Parent, 'Ids' => $Ids, 'ProductCategories' => $ProductCategories, 'TopCategories' => $TopCategories, 'TargetCategory' => $TargetCategory, 'forms' => $formViews, 'parent_id' => $parent_id, ]; } /** * @Route("/%eccube_admin_route%/product/sort/sort_no/move/{category_id}", name="admin_product_sort_sort_no_move", methods={"POST"}) */ public function moveSortNo(Request $request, CacheUtil $cacheUtil, $category_id = null) { if (!$request->isXmlHttpRequest()) { throw new BadRequestHttpException(); } if ($this->isTokenValid()) { $sortNos = $request->request->all(); foreach ($sortNos as $id => $sortNo) { $ProductCategory = $this->productCategoryRepository ->findOneBy(['product_id' => $id, 'category_id' => $category_id]); $ProductCategory->setSortNo($sortNo); $this->entityManager->persist($ProductCategory); } $this->entityManager->flush(); $cacheUtil->clearDoctrineCache(); return new Response('Successful'); } } }
- src/Eccube/Form/Type/Admin/ProductCategoryType.php
<?php namespace Eccube\Form\Type\Admin; use Eccube\Common\EccubeConfig; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; class ProductCategoryType extends AbstractType { /** * @var EccubeConfig */ protected $eccubeConfig; /** * ProductCategoryType constructor. * * @param EccubeConfig $eccubeConfig */ public function __construct(EccubeConfig $eccubeConfig) { $this->eccubeConfig = $eccubeConfig; } /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('sort_no', IntegerType::class, [ 'label' => '並び順(数字が大きいほど先に表示)', 'required' => false, 'constraints' => [ new Assert\Length([ 'max' => $this->eccubeConfig['eccube_int_len'], ]), new Assert\Regex([ 'pattern' => '/^[0-9\-]+$/u', 'message' => 'form_error.numeric_only', ]), ], ]); } /** * {@inheritdoc} */ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => 'Eccube\Entity\ProductCategory', ]); } /** * {@inheritdoc} */ public function getBlockPrefix() { return 'admin_product_category'; } }
- app/template/admin/Product/product_sort.twig
{% extends '@admin/default_frame.twig' %} {% set menus = ['product', 'product_sort'] %} {% block title %}商品並び替え{% endblock %} {% block sub_title %}{{ 'admin.product.product_management'|trans }}{% endblock %} {% form_theme form '@admin/Form/bootstrap_4_horizontal_layout.html.twig' %} {% block javascript %} <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.core.min.js', 'admin') }}"></script> <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.widget.min.js', 'admin') }}"></script> <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.mouse.min.js', 'admin') }}"></script> <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.sortable.min.js', 'admin') }}"></script> <script> $(function() { // 初期表示時のsort noを保持. var oldSortNos = []; $('.sortable-item').each(function() { oldSortNos.push(this.dataset.sortNo); }); oldSortNos.sort(function(a, b) { return a - b; }).reverse(); // 並び替え後にsort noを更新 var updateSortNo = function() { var newSortNos = {}; var i = 0; $('.sortable-item').each(function() { newSortNos[this.dataset.id] = oldSortNos[i]; $('.sort-no', $(this)).text(oldSortNos[i]); i++; }); $.ajax({ url: '{{ url('admin_product_sort_sort_no_move', {'category_id': parent_id}) }}', type: 'POST', data: newSortNos }).always(function() { $(".modal-backdrop").remove(); }); }; // 最初と最後の↑↓を再描画 var redrawDisableAllows = function() { var items = $('.sortable-item'); items.find('a.action-up').removeClass('disabled'); items.find('a.action-down').removeClass('disabled'); items.first().find('a.action-up').addClass('disabled'); items.last().find('a.action-down').addClass('disabled'); }; // オーバレイ後, 表示順の更新を行う var moveSortNo = function() { $('body').append($('<div class="modal-backdrop show"></div>')); updateSortNo(); redrawDisableAllows(); }; // Drag and Drop $('.sortable-container').sortable({ items: '> .sortable-item', cursor: 'move', update: function(e, ui) { moveSortNo(); } }); // Up $('.sortable-item').on('click', 'a.action-up', function(e) { e.preventDefault(); var current = $(this).parents("li"); if (current.prev().hasClass('sortable-item')) { current.prev().before(current); moveSortNo(); } }); // Down $('.sortable-item').on('click', 'a.action-down', function(e) { e.preventDefault(); var current = $(this).parents("li"); if (current.next().hasClass('sortable-item')) { current.next().after(current); moveSortNo(); } }); var groupItem = $('.list-group-item'); groupItem.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'); }); groupItem.on('click', 'button.action-edit-cancel', function(e) { e.preventDefault(); var current = $(this).parents('li'); current.find('[data-origin-value]').each(function(e) { $(this).val($(this).attr('data-origin-value')); }); current.find('.mode-view').removeClass('d-none'); current.find('.mode-edit').addClass('d-none'); }); groupItem.find('.is-invalid').each(function(e) { e.preventDefault(); var current = $(this).parents("li"); current.find('.mode-view').addClass('d-none'); current.find('.mode-edit').removeClass('d-none'); }); }); </script> {% endblock %} {% block stylesheet %} {#TODO: Move to css file#} <style> .c-directoryTree ul > li > ul li:not(:last-of-type) > label:before, .c-directoryTree ul > li > ul li:last-of-type > label:before { margin-right: 1.6em; } </style> {% endblock %} {% block main %} <div class="c-outsideBlock"> <div class="c-outsideBlock__contents mb-2"> <div class="row"> <div class="col-6"> <nav aria-label="breadcrumb" role="navigation"> <ol class="breadcrumb mb-2 p-0"> <li class="breadcrumb-item"> <a href="{{ url('admin_product_sort') }}"> {{ 'admin.product.category_all'|trans }} </a> </li> {% for ParentCategory in TargetCategory.path %} {% if ParentCategory.id is not null %} <li class="breadcrumb-item active" aria-current="page"> <a href="{{ url('admin_product_sort_show', { parent_id : ParentCategory.id }) }}"> {{ ParentCategory.name }} </a> </li> {% endif %} {% endfor %} </ol> </nav> </div> </div> </div> </div> <div class="c-contentsArea__cols"> <div class="c-contentsArea__primaryCol"> <div id="ex-primaryCol" 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"> <div class="row"> <div class="col-1"><strong> </strong></div> <div class="col-1"><strong>ID</strong></div> <div class="col-2"><strong>コード</strong></div> <div class="col-3"><strong>商品名</strong></div> <div class="col-1"><strong>価格</strong></div> <div class="col-1"><strong>順番</strong></div> <div class="col-3"><strong> </strong></div> </div> </li> {% if ProductCategories|length > 0 %} {% for ProductCategory in ProductCategories %} <li id="ex-category-{{ ProductCategory.product_id }}" class="list-group-item sortable-item" data-id="{{ ProductCategory.product_id }}" data-sort-no="{{ ProductCategory.sort_no }}"> {% if ProductCategory.category_id != TargetCategory.id %} <div class="row justify-content-around mode-view"> <div class="col-1 d-flex align-items-center"><i class="fa fa-bars text-ec-gray"></i></div> <div class="col-1 d-flex align-items-center">{{ ProductCategory.product_id }}</div> <div class="col-2 d-flex align-items-center"> {{ ProductCategory.Product.code_min }} {% if ProductCategory.Product.code_min != ProductCategory.Product.code_max %}<br>{{ 'admin.common.separator__range'|trans }}<br>{{ ProductCategory.Product.code_max }} {% endif %} </div> <div class="col-3 d-flex align-items-center"> <a href="{{ url('admin_product_product_edit', {'id': ProductCategory.product_id}) }}">{{ ProductCategory.Product.name }}</a> </div> <div class="col-1 d-flex align-items-center"> {{ ProductCategory.Product.price02_min|price }} {% if ProductCategory.Product.price02_min != ProductCategory.Product.price02_max %}<br>{{ 'admin.common.separator__range'|trans }}<br>{{ ProductCategory.Product.price02_max|price }} {% endif %} </div> <div class="col-1 d-flex align-items-center sort-no">{{ ProductCategory.sort_no }}</div> <div class="col-3 text-right"> <a class="btn btn-ec-actionIcon action-up mr-2 {% if loop.first %} disabled {% endif %}" href="" data-tooltip="true" data-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 action-down mr-2 {% if loop.last %} disabled {% endif %}" href="" data-tooltip="true" data-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 mr-2 action-edit" href="{{ url('admin_product_category_edit', {id: ProductCategory.product_id}) }}" data-tooltip="true" data-placement="top" title="{{ 'admin.common.edit'|trans }}"> <i class="fa fa-pencil fa-lg text-secondary"></i> </a> </div> </div> <form class="form-row d-none mode-edit" method="POST" action="{{ (Parent and Parent.id) ? url('admin_product_sort_show', {'parent_id': Parent.id}) : url('admin_product_sort') }}" enctype="multipart/form-data"> {{ form_widget(forms[ProductCategory.product_id]._token) }} <div class="col-auto align-items-center"> {{ form_widget(forms[ProductCategory.product_id].sort_no, {'attr': {'data-origin-value': forms[ProductCategory.product_id].sort_no.vars.value}}) }} {{ form_errors(forms[ProductCategory.product_id].sort_no) }} </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> {% endif %} </li> {% endfor %} {% else %} <li class="list-group-item"> カテゴリを選択してください。 </li> {% endif %} </ul> </div> </div> </div> <p>{{ 'admin.common.drag_and_drop_description'|trans }}</p> </div> </div> {% macro tree(Category, TargetId, level, Ids) %} {% import _self as selfMacro %} {% set level = level + 1 %} <li> <label {% if (Category.children|length > 0) and (Category.id not in Ids) %}class="collapsed" {% endif %}data-toggle="collapse" href="#directory_category{{ Category.id }}" aria-expanded="{% if Category.id in Ids %}true{% endif %}" aria-controls="directory_category{{ Category.id }}"></label> <span> <a href="{{ url('admin_product_sort_show', { parent_id : Category.id }) }}"{% if (Category.id == TargetId) %} class="font-weight-bold"{% endif %}>{{ Category.name }} ({{ Category.children|length }})</a></span> {% if Category.children|length > 0 %} <ul class="collapse list-unstyled {% if Category.id in Ids %}show{% endif %}" id="directory_category{{ Category.id }}"> {% for ChildCategory in Category.children %} {{ selfMacro.tree(ChildCategory, TargetId, level, Ids) }} {% endfor %} </ul> {% endif %} </li> {% endmacro %} <div class="c-contentsArea__secondaryCol"> <div id="ex-secondaryCol" class="c-secondaryCol"> <div class="card rounded border-0 mb-4"> <div class="card-header"> <span class="card-title"><a href="{{ url('admin_product_sort') }}">{{ 'admin.product.category_all'|trans }}</a></span> </div> <div class="card-body"> <div class="c-directoryTree mb-3"> {% import _self as renderMacro %} {% for TopCategory in TopCategories %} <ul class="list-unstyled"> {{ renderMacro.tree(TopCategory, TargetCategory.Parent.id | default(null), 0, Ids) }} </ul> {% endfor %} </div> </div> </div> </div> </div> </div> {% endblock %}
それぞれファイルを作成後、管理画面のメニューを表示させるために以下のファイルを修正します。
- app/config/eccube/packages/eccube_nav.yaml
parameters: eccube_nav: product: name: admin.product.product_management icon: fa-cube children: product_master: name: admin.product.product_list url: admin_product product_edit: name: admin.product.product_registration url: admin_product_product_new class_name: name: admin.product.class_management url: admin_product_class_name class_category: name: admin.product.category_management url: admin_product_category product_tag: name: admin.product.tag_management url: admin_product_tag product_csv_import: name: admin.product.product_csv_upload url: admin_product_csv_import category_csv_import: name: admin.product.category_csv_upload url: admin_product_category_csv_import product_sort: name: 商品並び替え url: admin_product_sort
eccube_nav.yaml
ファイルに対して、
product_sort: name: 商品並び替え url: admin_product_sort
を追加します。
以上で商品並び替え管理画面が作成されます。
ProductRepositoryの変更
商品並び替え機能を動作させるために、ProductRepositoryの変更を行います。
- src/Eccube/Repository/ProductRepository.php
<?php 〜 〜 〜 /** * get query builder. * * @param array $searchData * * @return \Doctrine\ORM\QueryBuilder */ public function getQueryBuilderBySearchData($searchData) { 〜 〜 〜 // category $categoryJoin = false; if (!empty($searchData['category_id']) && $searchData['category_id']) { $Categories = $searchData['category_id']->getSelfAndDescendants(); if ($Categories) { $qb ->innerJoin('p.ProductCategories', 'pct') ->innerJoin('pct.Category', 'c') ->andWhere($qb->expr()->in('pct.Category', ':Categories')) ->setParameter('Categories', $Categories) ->addOrderBy('c.hierarchy', 'DESC') ->addOrderBy('c.sort_no', 'DESC') ->addOrderBy('pct.sort_no', 'DESC'); $categoryJoin = true; } } 〜 〜 〜 // Order By // 標準 if ($categoryJoin === false) { $qb ->leftJoin('p.ProductCategories', 'pct') ->leftJoin('pct.Category', 'c'); $qb->addOrderBy('c.sort_no', 'DESC'); $qb->addOrderBy('pct.sort_no', 'DESC'); $qb->addOrderBy('c.hierarchy', 'ASC'); } // 価格低い順 $config = $this->eccubeConfig; if (!empty($searchData['orderby']) && $searchData['orderby']->getId() == $config['eccube_product_order_price_lower']) { 〜 〜 $qb->addOrderBy('price02_min', 'ASC'); 〜 〜 // 価格高い順 } elseif (!empty($searchData['orderby']) && $searchData['orderby']->getId() == $config['eccube_product_order_price_higher']) { 〜 〜 $qb->addOrderBy('price02_max', 'DESC'); 〜 〜 // 新着順 } elseif (!empty($searchData['orderby']) && $searchData['orderby']->getId() == $config['eccube_product_order_newer']) { 〜 〜 $qb->addOrderBy('p.create_date', 'DESC'); 〜 〜
こちらの修正では何を行っているかというと、dtb_product_category
テーブルに追加したsort_no
をソート順として追加して並び替えするように変更し、$qb->orderBy
から$qb->addOrderBy
に修正を行なっています。
ProductControllerの変更
検索結果が表示されるようにProdutControllerの変更を行います。変更箇所は以下の通りです。
- src/Eccube/Controller/ProductController.php
<?php 〜 〜 /** @var SlidingPagination $pagination */ $pagination = $paginator->paginate( $query, !empty($searchData['pageno']) ? $searchData['pageno'] : 1, !empty($searchData['disp_number']) ? $searchData['disp_number']->getId() : $this->productListMaxRepository->findOneBy([], ['sort_no' => 'ASC'])->getId(), ['wrap-queries' => true] ); 〜 〜
index
関数内にある$pagination
変数に設定する値に対して、['wrap-queries' => true]
を追加しています。
管理画面用ProductControllerの変更
管理画面で商品登録された時にも修正が必要ですので、以下の修正を行います。
- src/Eccube/Controller/Admin/Product/ProductController.php
<?php 〜 〜 // 追加 $ProductCategories = clone $Product->getProductCategories(); // カテゴリの登録 // 一度クリア 〜 〜 〜 〜 if (!isset($categoriesIdList[$ParentCategory->getId()])) { // 引数を追加 $ProductCategory = $this->createProductCategory($Product, $ParentCategory, $count, $ProductCategories); 〜 〜 if (!isset($categoriesIdList[$Category->getId()])) { // 引数を追加 $ProductCategory = $this->createProductCategory($Product, $Category, $count, $ProductCategories); 〜 〜 〜 〜 /** * ProductCategory作成 * * @param \Eccube\Entity\Product $Product * @param \Eccube\Entity\Category $Category * @param integer $count * @param $ProductCategories * * @return \Eccube\Entity\ProductCategory */ private function createProductCategory($Product, $Category, $count, $ProductCategories) { $ProductCategory = new ProductCategory(); $ProductCategory->setProduct($Product); $ProductCategory->setProductId($Product->getId()); $ProductCategory->setCategory($Category); $ProductCategory->setCategoryId($Category->getId()); /** @var ProductCategory $item */ foreach ($ProductCategories as $item) { if ($item->getCategory()->getId() == $Category->getId()) { $ProductCategory->setSortNo($item->getSortNo()); break; } else { $ProductCategory->setSortNo($count); } } return $ProductCategory; }
edit
関数内で、sort_no
を追加したことによる修正を行います。
以上で商品並び替え機能が実装できます。商品CSV登録にも対応が必要となりますが、管理画面の商品登録時と同じような実装をsrc/Eccube/Controller/Admin/Product/CsvImportController.php
に対して行えば対応可能です。
もし動作しないとかありましたらコメントしてください。