[ECCUBE4] TwigにPHP関数を追加。もちろん本体ソースは触らずに

今リアルにECCUBE4案件が動き出しているのでECCUBE4関係のメモが続きます
今日はTwigに関数を追加する方法です

Twigはテンプレートエンジンですので、その中でデータ処理を行うようなものではありません
ありません・・・が、ほんとにちょっとした処理ぐらいならTwig上でやってしまったほうがわかりやすくなることがあるのも事実

今回は、配列の中で、指定した値を持つ要素の、そのキーが知りたい、という場面が発生しました
PHPならarray_searchを使う場面です
Twigではin_array(指定した値を持つ要素があるかないか判断)的な処理はできるものの、そのキーを返すものは用意されてないようなので、独自でarray_searchに相当するヘルパー関数を追加したい – それもECCUBE4本体のファイルを編集することなく

検索すると先達が方法を投稿してくれていました

[EC-CUBE4] Twig内で使用できる独自のヘルパー関数を追加する方法

こちらの解説ではオリジナルな関数を作っていますが、今回はPHPのarray_searchをそのまんま使えばいいので、もっとシンプルになりました
下記ではarray_searchに加えてついでにarray_flipも加えてます

app/Customize/Twig/Extension/EccubeExtension.php

<?php
namespace Customize\Twig\Extension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class EccubeExtension extends AbstractExtension
{
    public function getFunctions()
    {
        return [
            new TwigFunction('array_search', 'array_search'),
            new TwigFunction('array_flip', 'array_flip'),
        ];
    }
}

使い方はPHPそのまんまです

{% array_search(needle , some_array) %}

[twig] twigで再帰呼び出し

twigで再帰呼び出しができると知りました
そのような処理が必要なときはできるだけPHPで前処理してテンプレートに渡すようにしてましたが、twigで処理できるならそのほうがスマートな場合も多そうです

きっかけはECCUBE4のソース
カテゴリを表示している場所で再帰呼び出しをしています

src/Eccube/Resource/template/default/Block/category_nav_pc.twig

{% set Categories = repository('Eccube\\Entity\\Category').getList() %}

{% macro tree(Category) %}
    {% from _self import tree %}
    <a href="{{ url('product_list') }}?category_id={{ Category.id }}">
        {{ Category.name }}
    </a>
    {% if Category.children|length > 0 %}
        <ul>
            {% for ChildCategory in Category.children %}
                <li>
                    {{ tree(ChildCategory) }}
                </li>
            {% endfor %}
        </ul>
    {% endif %}
{% endmacro %}

{# @see https://github.com/bolt/bolt/pull/2388 #}
{% from _self import tree %}

<div class="ec-categoryNaviRole">
    <div class="ec-itemNav">
        <ul class="ec-itemNav__nav">
            {% for Category in Categories %}
                <li>
                    {{ tree(Category) }}
                </li>
            {% endfor %}
        </ul>
    </div>
</div>

上記のソースでは前半部分でmacroを定義して、後半でそれを一回呼び出してます
呼びされたmacroの中でさらにまた自分自身を呼び出すことで、カテゴリの深さのぶんだけ再帰的に処理されるようになっています

[ECCUBE4] twigでのasset関数と同じ処理をController上でする

ECCUBE4で、例えば商品画像のURLを取得する場合、デザインテンプレート(twig)上では以下のように指定します

{{ asset('hogehoge.png', 'save_image') }}

// 出力結果
/html/upload/save_image/hogehoge.png

asset関数に個別のファイル名を渡せば’save_image’までのパスも付け足して返してくれるわけですね

これと同じことをContoller上でしたい!
たったこれだけのことにだいぶ調べるハメとなりました

ECCUBE3では image_save_urlpath というのが定義されているので

$app['config']['image_save_urlpath'].'/hogehoge.png'

とでもするだけの話だったのですが、4では探せども探せどもimage_save_urlpathに該当するものが見つからない…
eccube_save_image_dir というものはありましたが、そのurlpath版は無いようなのです
※ この場合、dirのほうはサーバOS上でのファイルのパス、urlpathはWEB上でのドキュメントルートからのパスを指します

結論としては以下のようにして’save_image’まで込みでファイルパスを取得できます

use Eccube\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Asset\Packages;

class HogeController extends AbstractController
{
    /**
     * @var Packages
     */
    protected $packages;

    /**
     * HogeController constructor.
     *
     * @param Packages $packages
     */
    public function __construct(
        Packages $packages
    ) {
        $this->packages = $packages;
    }

    public function index(Request $request)
    {
        $filepath = $this->packages->getUrl('hogehoge.png', 'save_image');
        
        ....
    }
}

Symfony\Component\Asset\PackagesgetUrlで、twigのassetでやっているのと同じ処理ができました

やり方が分かってしまえば、configからパスを取り出すECCUBE3流より、ECCUBE4流の書き方がスマートに見えますね

[ECCUBE4] プラグインに同梱したリソースファイルのコピー

今日もECCUBE4の勉強を進めています

dataTablesを使うプラグインを試作中

プラグインを作った際、JSやCSSのリソースファイルを同梱したいのはよくある話かと思います
プラグインに同梱したリソースファイルを実際に使うには本体側へのコピーが必要になるのですが、ECCUBE3ではそれはプラグインの製作者がプラグインマネージャーにて自前でやっていました

EC-CUBE 3.0 リソースファイル、ブロック

これがECCUBE4ではプラグインインストール時に自動で行われるそうです

リソースコピーの使い方

[プラグインディレクトリ]/Resource/assets

にあるファイルが

[ECCUBEインストールディレクトリ]/html/plugin/[プラグインcode]/assets

にコピーされます

twig内でリソースを使いたい際は以下のようになります

{{ asset('[プラグインcode]/assets/[リソースのパス]', 'plugin') }}

// 例えばHogeHogeプラグインでcss/datatables.min.cssを読み込む場合は
{% block stylesheet %}
<link rel="stylesheet" href="{{ asset('HogeHoge/assets/css/datatables.min.css', 'plugin') }}"/> 
{% endblock %}

参考ページ
4系プラグインのPluginManagerの書き方

[ECCUBE] ECCUBE4のプラグインを学ぶために商品レビュープラグインを改造して返信機能をつけてみる

ECCUBE4学習の3日目です
プラグインサンプルとして提供されている商品レビュープラグインを改造してみようと思います

githubからzipを落としてきて app/Plugin の下に置きます
2019年6月24日の段階では開発ドキュメントにProductReviewの名前で展開するように書かれていますが、実際はProductReview4とします
(そうしなければ次のインストールに失敗します)

$ cd ~/apps/ec-cube/app/Plugin
$ wget https://github.com/EC-CUBE/ProductReview-plugin/archive/4.0.zip
$ unzip 4.0.zip
$ mv ProductReview-plugin-4.0 ProductReview4
$ rm 4.0.zip

落としてきたProductReview4プラグインのインストール&有効化

$ cd ~/apps/ec-cube/
$ bin/console eccube:plugin:install --code=ProductReview4
$ bin/console eccube:plugin:enable --code=ProductReview4
ProductReview4プラグインのインストール完了

基本設定(プラグイン名やバージョン)は composer.json に書く、と。ここで追加のcomposerも書けるのかー
ふむふむふむ…

見てるだけでは頭に入らないので、改造することでもう少し理解を進めてみます
レビューにショップオーナーからの返信機能を付け加えてみます

データベースの定義は・・・
それらしいのは Entity/ProductReview.php しか見つからない。ここに見よう見まねで付け加えてみましょうか
apps/ec-cube/app/Plugin/ProductReview4/Entity/ProductReview.php の末尾に以下のように追加

/**
 * @var string
 *
 * @ORM\Column(name="reply", type="text", nullable=true)
 */
private $reply;

/**
 * Get reply.
 *
 * @return string
 */
public function getReply()
{
    return $this->reply;
}

/**
 * Set reply.
 *
 * @param string $reply
 *
 * @return ProductReview
 */
public function setReply($reply)
{
    $this->reply = $reply;

    return $this;
}

DBに反映されるのはインストール時だと思うので、一度無効化>アンインストールしてから、再度インストール>有効化してみます

$ bin/console eccube:plugin:disable --code=ProductReview4
$ bin/console eccube:plugin:uninstall --code=ProductReview4
$ bin/console eccube:plugin:install --code=ProductReview4
$ bin/console eccube:plugin:enable --code=ProductReview4

mysqlにログインしてテーブル構造確認( show columns from plg_product_review )すると・・・

おお、ちゃんとカラムができてる!
このあともう少し調べてみたら、なにも再インストールをしなくとも
bin/console eccube:plugin:schema-update ProductReview4
だけでDB再定義できることもわかりました

では、お店からの返信を入力できるように管理画面用のFormとtwigを改造します

app/Plugin/ProductReview4/Form/Type/Admin/ProductReviewType.php
こちらのcommentの定義の後ろに追加

~~前略~~
            ->add('comment', TextareaType::class, [
                'constraints' => [
                    new Assert\NotBlank(),
                    new Assert\Length(['max' => $config['eccube_ltext_len']]),
                ],
                'attr' => [
                    'maxlength' => $config['eccube_ltext_len'],
                ],
            ])
            ->add('reply', TextareaType::class, [
                'required' => false,
                'constraints' => [
                    new Assert\Length(['max' => $config['eccube_ltext_len']]),
                ],
                'attr' => [
                    'maxlength' => $config['eccube_ltext_len'],
                ],
            ])
            ;

app/Plugin/ProductReview4/Resource/template/admin/edit.twig
こちらもコメントの後ろに追加

<div class="row mb-2">
    <div class="col-3">
        <span>{{ 'コメント'|trans }}</span>
        <span class="badge badge-primary ml-1">{{ 'product_review.common.required'|trans }}</span>
    </div>
    <div class="col">
        {{ form_widget(form.comment, {'attr': {'rows': '6'}}) }}
        {{ form_errors(form.comment) }}
    </div>
</div>

<div class="row mb-2">
    <div class="col-3">
        <span>{{ '返信'|trans }}</span>
    </div>
    <div class="col">
        {{ form_widget(form.reply, {'attr': {'rows': '6'}}) }}
        {{ form_errors(form.reply) }}
    </div>
</div>
管理画面でお店からの返信を入力できるかの確認

管理画面に返信欄を追加できました

フロント画面にも返信を反映させましょう
app/Plugin/ProductReview4/Resource/template/default/review.twig
前半のCSS部分と後半のHTML部分に追加

~~略~~

<style type="text/css">

    #product_review_area {
        border-top: 1px solid #E8E8E8;
        padding-bottom: 0;
        margin-bottom: 20px;
    }

    #product_review_area .ec-rectHeading {
        cursor: pointer;
        margin-top: 20px;
        margin-bottom: 20px;
    }

    #product_review_area .ec-rectHeading.is_active i {
        transform: rotate(180deg);
    }

    #product_review_area .review_list {
        padding-left: 25px;
    }

    #product_review_area .review_list li {
        margin-bottom: 16px;
    }

    #product_review_area .review_list .review_date {
        font-weight: bold;
    }

    #product_review_area .recommend_average {
        margin-left: 16px;
        color: #DE5D50;
    }

    #product_review_area .review_list .recommend_level {
        margin-left: 16px;
        color: #DE5D50;
    }

    #product_review_area .review_list .recommend_name {
        margin-left: 16px;
    }

    #product_review_area .review_list .review_reply {
        margin-left: 32px;
        margin-top: 12px;
        padding-left: 12px;
        color: #999;
        border-left: 2px solid #999;
    }

</style>

<script>
    $(function() {
        $('#product_review_area').appendTo($('div.ec-layoutRole__main, div.ec-layoutRole__mainWithColumn, div.ec-layoutRole__mainBetweenColumn'));

        $('#product_review_area .ec-rectHeading').on('click', function() {
            $content = $('#reviewContent');
            if ($content.css('display') == 'none') {
                $(this).addClass('is_active');
                $content.addClass('is_active');
                $content.slideDown(300);
            } else {
                $(this).removeClass('is_active');
                $content.removeClass('is_active');
                $content.slideUp(300);
            }
            return false;
        });
    });
</script>

<!--▼レビューエリア-->
<div id="product_review_area">
    <div class="ec-role">
        {% set positive_avg_star = ProductReviewAvg %}
        {% set nagative_avg_star = 5 - positive_avg_star %}

        <div class="ec-rectHeading is_active">
            <h4>{{ 'product_review.front.product_detail.title'|trans }}
                <!--平均の星の数-->
                <span class="recommend_average">{{ stars.stars(positive_avg_star, nagative_avg_star) }}</span>
                <!--レビュー数-->
                <span>({{ ProductReviewCount }})</span>
                <span class="chevron pull-right">
                    <i class="fas fa-angle-up fa-lg"></i>
                </span>
            </h4>
        </div>
        <div id="reviewContent">
            {% if ProductReviews %}
                <ul class="review_list">
                    {% for ProductReview in ProductReviews %}
                        <li>
                            <p class="review_date">
                                <!--投稿日-->
                                {{ ProductReview.create_date|date_day }}

                                <!--投稿者-->
                                <span class="recommend_name">
                                            {% if ProductReview.reviewer_url %}
                                                <a href="{{ ProductReview.reviewer_url }}"
                                                   target="_blank">{{ 'product_review.front.product_detail.name'|trans({ '%name%': ProductReview.reviewer_name }) }}</a>
                                            {% else %}
                                                {{ 'product_review.front.product_detail.name'|trans({ '%name%': ProductReview.reviewer_name }) }}
                                            {% endif %}
                                        </span>

                                <!--星の数-->
                                {% set positive_star = ProductReview.recommend_level %}
                                {% set nagative_star = 5 - positive_star %}
                                <span class="recommend_level">
                                            {{ stars.stars(positive_star, nagative_star) }}
                                        </span>
                            </p>

                            <!--タイトル-->
                            <strong>{{ ProductReview.title }}</strong>

                            <!--レビューコメント-->
                            <p>{{ ProductReview.comment|nl2br }}</p>

                            <!--レビュー返信-->
                            {% if ProductReview.reply %}
                            <div class="review_reply">
                                <span class="text-info">お店からの返信</span>
                                <p>{{ ProductReview.reply|nl2br }}</p>
                            </div>
                            {% endif %}
                        </li>
                    {% endfor %}
                </ul>
            {% else %}
                <p>{{ 'product_review.front.product_detail.no_review'|trans }}</p>
            {% endif %}
        </div>
        <div>
            <a href="{{ url('product_review_index', { id: Product.id }) }}"
               class="ec-inlineBtn--action">{{ 'product_review.front.product_detail.post_review'|trans }}</a>
        </div>
    </div>
</div>
<!-- ▲レビューエリア -->
商品詳細画面でレビューにお店から返信も追加

これでお店からの返信機能が出来上がりました!

ここまでの改造を反映させた返信機能付き商品レビュープラグインを公開しておきます
ProductReview-custom.zip

[ECCUBE] ECCUBE4をカスタマイズしてみる

昨日ECCUBE4のインストールはできたので、今日はカスタマイズの実践をしてみたいと思います
実際の案件でよくある、商品情報に項目を追加したい!をやってみます

Entityのカスタマイズ

ECCUBE3となにより違うのは、app/Customizeというディレクトリがあること
ECCUBE3ではカスタマイズ用のベースプラグインを作ったりしてたけどそれが要らなくなったんですね

開発ドキュメントをそのままなぞる形で商品情報にメーカー名を入れるカスタマイズをします。

app/Customize/Entity/ProductTrait.php

<?php

namespace Customize\Entity;

use Doctrine\ORM\Mapping as ORM;
use Eccube\Annotation\EntityExtension;

/**
  * @EntityExtension("Eccube\Entity\Product")
 */
trait ProductTrait
{
    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     * @Eccube\Annotation\FormAppend(
     *     auto_render=false,
     *     type="\Symfony\Component\Form\Extension\Core\Type\TextType",
     *     options={
     *          "required": false,
     *          "label": "メーカー名"
     *     })
     */
    public $maker_name;
}

Entityを作ったらそこからProxyクラスを生成します
Proxy…ECCUBE3にはなかった用語ですね

$ bin/console eccube:generate:proxies
gen -> /opt/bitnami/apps/ec-cube/app/proxy/entity/Product.php

出来上がった app/proxy/entity/Product.php を見るとECCUBE3時代のEntityファイルのような内容
EC4のEntity ≒ EC3のdoctrine/*dcm.yml
EC4のProxy ≒ EC3のEntity
みたいな理解でいいのかな

Proxyができたらそれをデータベースに反映させます

$ bin/console doctrine:schema:update --dump-sql --force

テンプレートをカスタマイズ

さきほど作ったEntityに@Eccube\Annotation\FormAppendを記述したのでこれでもうフォームを使う準備は整っています
あとは管理画面の商品管理画面にメーカー名の項目を追加するよう、カスタマイズtwigを作成します

src/Eccube/Resource/template/admin/Product/product.twig
をそのまま以下にコピーして
app/template/admin/Product/product.twig

商品説明と販売価格(これはproduct_classがないときだけ出てくる項目)の間にメーカー名フォームを記述

 ~~ 前略 ~~

                    {{ form_widget(form.description_list, { attr : { rows : "4"} }) }}
                    {{ form_errors(form.description_list) }}
                </div>
            </div>
        </div>
    </div>
    
    {# maker_nameフォーム #}
    <div class="row">
        <div class="col-3">
            <div class="d-inline-block">
                <span>{{ form.maker_name.vars.label|trans }}</span>
            </div>
        </div>
        <div class="col mb-2">
            {{ form_widget(form.maker_name) }}
            {{ form_errors(form.maker_name) }}
        </div>
    </div>
    
    {% if has_class == false %}
        <div class="row">
            <div class="col-3">
                <div class="d-inline-block">
                    <span>{{ 'admin.product.sale_price'|trans }}</span>
                    
~~ 後略 ~~

以上で商品情報にメーカー名が追加できました!

[ECCUBE] ログイン後の遷移先をマイページに

ECCUBE 3.0系の話。
標準ではmypage/loginからログインするとトップページに遷移します。
それをマイページに変更したいという要望がありました。

これに対応するにはログイン処理のイベントを拾って云々…になるかと思いきや、意外にもtwigの改造だけで対処できることがわかりました。

Mypage/login.twig

{% if app.session.flashBag.has('eccube.login.target.path') %}
	{% for targetPath in app.session.flashBag.get('eccube.login.target.path') %}
		<input type="hidden" name="_target_path" value="{{ targetPath }}" />
	{% endfor %}
{% endif %}

上記のようになっている部分があります。
_target_pathというパラメータで遷移先を指定できるんですね。

ここでflashBagセッションに遷移先が入っていればそこを_target_pathに指定する処理が書かれていますが、mypage/loginからの一般的な操作だと特に指定がないためにデフォルト=トップページに飛んでいるようです。

そこでflashBagセッションに遷移先指定がない場合、_target_pathにマイページを指定するようにして対応しました。

{% if app.session.flashBag.has('eccube.login.target.path') %}
	{% for targetPath in app.session.flashBag.get('eccube.login.target.path') %}
		<input type="hidden" name="_target_path" value="{{ targetPath }}" />
	{% endfor %}
{% else %}
		<input type="hidden" name="_target_path" value="{{ url('mypage') }}" />
{% endif %}