luyadev/luya-module-cms

View on GitHub
src/widgets/NavTree.php

Summary

Maintainability
A
2 hrs
Test Coverage
A
100%
<?php

namespace luya\cms\widgets;

use luya\base\Widget;
use luya\cms\menu\Item;
use luya\cms\menu\QueryIteratorFilter;
use luya\helpers\ArrayHelper;
use luya\traits\CacheableTrait;
use Yii;
use yii\caching\DbDependency;
use yii\helpers\Html;

/**
 * Build a Navigation from top down, based on the given item or with a specific menu query.
 *
 * Build the navigation from top:
 *
 * ```php
 * Nav::widget();
 * ```
 *
 * Build the navigation for a given item (all subpages)
 *
 * ```php
 * Nav::widget(['startItem' => Yii::$app->menu->current]);
 * ```
 *
 * Example with all options:
 *
 * ```php
 * NavTree::widget([
 *    'findQuery' => Yii::$app->menu->find()->where(['container' => 'default', 'parent_nav_id' => 0])->all(),
 *    // or
 *    'startItem' => Yii::$app->menu->home, // if defined, startItem will be used, otherwise findQuery
 *    'maxDepth' => 2,
 *    'linkActiveClass' => 'link-active',
 *    'itemActiveClass' => 'item-active',
 *    'listDepthClassPrefix' => 'list-depth-',
 *    'wrapperOptions' => [
 *        'tag' => 'nav',
 *        'class' => 'navigation'
 *    ],
 *    'itemOptions' => [
 *        'tag' => 'li',
 *        'class' => 'item depth-{{depth}} alias-{{alias}}'
 *    ],
 *    'linkOptions' => [
 *        'tag' => 'a',
 *        'class' => 'link depth-{{depth}} alias-{{alias}}'
 *    ],
 * ]);
 * ```
 *
 * @property Item $startItem Generate submenus for all children below of this menu Item. If not defined $findQuery will be used.
 * @property QueryIteratorFilter $findQuery
 *
 * @author Marc Stampfli <kontakt@marcstampfli.guru>
 * @since 1.0.0
 */
class NavTree extends Widget
{
    use CacheableTrait;

    private ?\luya\cms\menu\Item $_startItem = null;

    /**
     * @var null|QueryIteratorFilter The menu Query. If not defined the following query will be used `Yii::$app->menu->find()->where(['container' => 'default', 'parent_nav_id' => 0])->all()`
     */
    private ?\luya\cms\menu\QueryIteratorFilter $_findQuery = null;

    /**
     * @var string If the nav tree widget is used multiple times on the same side (for example for the main navigation and the mobile menu)
     * then its possible to define a variation which then makes the cache identifier unique.
     * @since 4.0.0
     */
    public $variation;

    /**
     * @var null|integer If set the depth of the menu will be limited
     */
    public $maxDepth;

    /**
     * @var string The class that should be set on the *active link*
     */
    public $linkActiveClass = 'nav__link--active';

    /**
     * @var string The class that should be set on the *active item*
     */
    public $itemActiveClass = '';

    /**
     * @var string This prefix will be set in front of the depth number on the list class e.g. list-depth-2
     */
    public $listDepthClassPrefix = 'nav__list--';

    /**
     * @var boolean Decides whether the first <ul> tag will be outputted or not
     */
    public $ignoreFirstListTag = false;

    /**
     * @var null|array If set, a wrapper will be wrapped around the list
     * - tag: The tag for the wrapper, e.g. `nav`
     * - class: Class or classes for the wrapper
     *
     * You can set all possible html attributes as options
     */
    public $wrapperOptions;

    /**
     * @var array Options for the lists that are generated
     * - tag: The tag for the list, default is `ul`
     * - class: Class or classes for the list
     *
     * You can set all possible html attributes as options
     */
    public $listOptions = [
        'class' => 'nav__list'
    ];

    /**
     * @var array Options for the items that are generated
     * - tag: The tag for the item, default is `li`
     * - class: Class or classes for the item
     *
     * You can set all possible html attributes as options
     */
    public $itemOptions = [
        'class' => 'nav__item nav__item--{{alias}}'
    ];

    /**
     * @var array Options for the links that are generated
     * - tag: The tag for the link, default is `a`
     * - class: Class or classes for the link
     *
     * You can set all possible html attributes as options
     * **Note: href and content will be set regardless of the options**
     */
    public $linkOptions = [
        'class' => 'nav__link'
    ];

    /**
     * @var callable A callable which can be used to generate the link content, the first argument of the function
     * is the {{luya\cms\menu\Item}}:
     *
     * ```php
     * 'titleContent' => function(luya\cms\menu\Item $item) {
     *     return '<i class="fa fa-edit">'. $item->title;
     * }
     * ```
     *
     * If no callable is defined, which is default, then the {{luya\cms\menu\Item::$title}} is returned.
     * @since 3.4.0
     */
    public $titleContent;

    /**
     * @var null|string The list tag will be set during init
     */
    private $_listTag;

    /**
     * @var null|string The item tag will be set during init
     */
    private $_itemTag;

    /**
     * @var null|string The link tag will be set during init
     */
    private $_linkTag;

    /**
     * Configure not defined properties
     */
    private function autoConfigure()
    {
        $this->_listTag = ArrayHelper::remove($this->listOptions, 'tag', 'ul');
        $this->_itemTag = ArrayHelper::remove($this->itemOptions, 'tag', 'li');
        $this->_linkTag = ArrayHelper::remove($this->linkOptions, 'tag', 'a');

        if ($this->findQuery === null && $this->startItem === null) {
            $this->findQuery = Yii::$app->menu->find()->where(['container' => 'default', 'parent_nav_id' => 0])->all();
        }
    }

    /**
     * @return string HTML representation of the generated menu
     */
    public function run()
    {
        return $this->getOrSetHasCache($this->generateCacheKey(), function () {
            $this->autoConfigure();

            $html = "";

            if ($this->startItem === null) {
                $html = $this->buildList($this->findQuery);
            } elseif ($this->startItem->hasChildren) {
                $html = $this->buildList($this->startItem->children);
            }

            if ($this->wrapperOptions !== null) {
                $wrapperTag = ArrayHelper::remove($this->wrapperOptions, 'tag', 'nav');
                $html = Html::tag($wrapperTag, $html, $this->wrapperOptions);
            }

            return $html;
        }, 0, new DbDependency(['sql' => 'SELECT max(timestamp_update) FROM cms_nav_item']));
    }

    /**
     * Generate a cache key with certain criteriums
     *
     * @return array
     */
    private function generateCacheKey()
    {
        $key = [
            self::class,
            Yii::$app->composition->langShortCode,
            $this->variation,
            $this->linkActiveClass,
            $this->itemActiveClass,
            $this->listDepthClassPrefix,
            Yii::$app->menu->current->id,
        ];

        if ($this->startItem) {
            $key[] = $this->startItem->id;
        };

        return $key;
    }

    /**
     * Builds the list for the given Iterator and recursively calls itself to also generate the menu for all children
     *
     * @param QueryIteratorFilter $iterator The iterator used to build the list
     * @param int $i The counter that is used to set the list depth
     * @return string Part of the menu
     */
    private function buildList(QueryIteratorFilter $iterator, $i = 1)
    {
        // Abort if maxDepth is set & reached
        if ($this->maxDepth !== null && $i >= ($this->maxDepth + 1)) {
            return "";
        }

        // Add the listDepthClassPrefix manually
        $listOptions = $this->listOptions;

        if (!isset($listOptions['class'])) {
            $listOptions['class'] = "";
        }

        $listOptions['class'] .= " " . $this->listDepthClassPrefix . $i;

        // <ul>
        $html = "";

        if ($this->ignoreFirstListTag && $i !== 1 || !$this->ignoreFirstListTag) {
            $html = Html::beginTag($this->_listTag, $listOptions);
        }

        /** @var Item $item */
        foreach ($iterator as $item) {
            $itemOptions = $this->itemOptions;
            $linkOptions = array_merge($this->linkOptions, ['href' => $item->link, 'target' => $item->getTarget()]);

            // Set the active classes if item is active
            if ($item->isActive) {
                $itemOptions['class'] .= $this->itemActiveClass !== null ? ' ' . $this->itemActiveClass : '';
                $linkOptions['class'] .= $this->linkActiveClass !== null ? ' ' . $this->linkActiveClass : '';
            }

            // <li>
            $html .= Html::beginTag($this->_itemTag, $this->compileOption($item, $itemOptions));

            // <a></a>
            $html .= Html::tag($this->_linkTag, $this->titleContent ? call_user_func($this->titleContent, $item) : $item->title, $this->compileOption($item, $linkOptions));

            // Recursive iterate if item has Children
            if ($item->hasChildren) {
                $html .= $this->buildList($item->children, $i + 1);
            }

            // </li>
            $html .= Html::endTag($this->_itemTag);
        }

        if ($this->ignoreFirstListTag && $i !== 1 || !$this->ignoreFirstListTag) {
            // </ul>
            $html .= Html::endTag($this->_listTag);
        }

        return $html;
    }

    /**
     * Replaces the placeholders, for example {{alias}}, with the value stored in $item
     * If the placeholder name isn't found as a property, it will be returned (e.g. alias)
     *
     * @param Item|null $item
     * @return array
     */
    private function compileOption(Item $item, array $options)
    {
        foreach ($options as $key => $option) {
            $options[$key] = preg_replace_callback('/{{([^}]*)}}/', function ($match) use ($item) {
                if ($item && $item->hasProperty($match[1])) {
                    $f = $match[1];
                    return $item->$f;
                }

                return $match[1];
            }, $option);
        }

        return $options;
    }

    public function setStartItem(Item $item)
    {
        $this->_startItem = $item;
    }

    /**
     * @return Item
     */
    public function getStartItem()
    {
        return $this->_startItem;
    }

    public function setFindQuery(QueryIteratorFilter $findQuery = null)
    {
        $this->_findQuery = $findQuery;
    }

    /**
     * @return QueryIteratorFilter|null
     */
    public function getFindQuery()
    {
        return $this->_findQuery;
    }
}