Многоуровневое меню в Laravel

Многоуровневое меню в Laravel

Сегодня рассмотрим, как можно создать и вывести многоуровневое меню в Laravel. Естественно, без рекурсии дело не обойдётся, но при этом будут некоторые интересные моменты - на помощь придут функции-хэлперы.

Немного о том, что будем делать и чего не будем. Основное внимание уделим непосредственно построению меню, т.е. миграции, получению данных из базы и преобразованию их к древовидному массиву (точнее коллекции), а также выводу меню во фронте. Будем использовать ViewComposer, но подробно останавливаться на этой теме не вижу смысла, так как ранее она была раскрыта здесь. Также не затронём CRUD - там всё стандартно. Что ж, поехали.

Первым делом создадим модель и миграцию. Пишем в консоли команду:

php artisan make:model Menu -m

и нажимаем Enter. Затем редактируем только что созданный файл миграции (который находится в директории app/database/migrations). При этом всё же подумаем о том, что нам хотелось бы и сортировать пункты меню и публиковать / снимать с публикации отдельные пункты, за что будут отвечать поля live и sort_order соответственно:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateMenusTable extends Migration
{
    public function up()
    {
        Schema::create('menus', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('parent_id')->unsigned()->nullable()->index();
            $table->string('name');
            $table->string('url');
            $table->tinyInteger('sort_order')->default(0);
            $table->boolean('live')->default(true);
        });
    }

    public function down()
    {
        Schema::dropIfExists('menus');
    }
}

 

Как Вы уже догадались, parent_id - поле, в котором хранится идентификатор родительского пункта. Если родителя нет, значит данный пункт меню является элементом первого уровня, а в поле будет храниться null.

Далее, как обычно:

php artisan migrate

 

Переходим к модели. Фактические отношения между пунктами меню "один ко многим" (у одного родителя может быть много вложенных пунктов) и "многие к одному" - много вложенных пунктов относятся к одному родителю. А значит, в модели нужно было бы прописать что-то вроде:

public function parent()
{
    return $this->belongsTo(Menu::class, 'parent_id');
}

public function children()
{
    return $this->hasMany(Menu::class, 'parent_id');
}

 

Но ситуация интересна тем, что последующий ниже код не использует эти отношения, а значит можно не заморачиваться - всё и так будет прекрасно работать. Вместо этого подумаем о скоупах. Во-первых, нужна возможность доставать из базы и выводить только те пункты меню, которые опубликованы. Во-вторых, далее нужно будет отсортировать пункты по идентификатору родителя и порядку сортировки, но не хотелось бы в контроллере повторяться и писать ...orderBy(...)->orderBy(...)->..., а потому создадим метод, который будет выполнять эту работу. И да, данные о времени создания и изменения - timestamps - хранить не будем, о чём тоже следует "сказать" модели. Вот её полный код:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Menu extends Model
{
    protected $fillable = [
        'parent_id',
        'name',
        'url',
        'live'
    ];

    public $timestamps = false;

    public function scopeIsLive($query)
    {
        return $query->where('live', true);
    }

    public function scopeOfSort($query, $sort)
    {
        foreach ($sort as $column => $direction) {
            $query->orderBy($column, $direction);
        }

        return $query;
    }
}

 

Поскольку имеем дело с меню, которое будет выводится практически на всех страницах сайта, разумно воспользоваться композером. Как уже говорил, подробнее можете прочитать в этой статье, а здесь лишь напомню в нескольких словах: создаём и регистрируем специального провайдера ComposerServiceProvider и указываем его в массиве поставщиков в файле app/config/app.php. Затем создаём директорию ViewComposers и в ней файл NavigationComposer.php с одноименным классом. В этом классе будем доставать данные из базы и передавать в соответствующее представление (например, resources/layouts/partials/_nav.blade.php). Посмотрим на метод compose() класса NavigationComposer, при этом допустим что у нас есть некий метод buildTree(), который строит дерево из входящих данных:

public function compose(View $view)
{
    $menuitems = Menu::isLive()
        ->ofSort(['parent_id' => 'asc', 'sort_order' => 'asc'])
        ->get();

    $menuitems = $this->buildTree($menuitems);

    return $view->with('menuitems', $menuitems);
}

 

Как бы всё просто - берём только полученные пункты меню, сортируем их, преобразовываем в дерево и отправляем в представление. А вот теперь об интересном - как построить это самое дерево?

Если покопаться в документации, то можно найти метод groupBy(), который группирует коллекцию по заданному ключу. И вот что сделаем: сгруппируем пункты по parent_id, а затем пройдёмся по первоначальной коллекции и, если в коллекции есть группа с ключём, идентичным id текущего пункта, создадим атрибут children и присвоим эту группу. Другими словами, теперь каждый пукнт будет иметь созданное нами свойство с группой дочерних элементов. Причём, элементы уже будут отсортированы в нужном порядке - это было сделано на предыдущем шаге запросом в базу данных. Осталось только вернуть элементы первого уровня - каждый из которых уже содержит группу дочерних элементов, каждый элемент которой также уже включает... Ну в общем, Вы поняли )

Вот код:

<?php

namespace App\Http\ViewComposers;

use Illuminate\View\View;
use App\Menu;

class NavigationComposer
{
    public function compose(View $view)
    {
        $menuitems = Menu::isLive()
            ->ofSort(['parent_id' => 'asc', 'sort_order' => 'asc'])
            ->get();

        $menuitems = $this->buildTree($menuitems);

        return $view->with('menuitems', $menuitems);
    }

    public function buildTree($items)
    {
        $grouped = $items->groupBy('parent_id');

        foreach ($items as $item) {
            if ($grouped->has($item->id)) {
                $item->children = $grouped[$item->id];
            }
        }

        return $items->where('parent_id', null);
    }
}

 

И хотя в этом куске кода явной рекурсии нет, полагаю, Вы догадываетесь, что её мы увидим, если глянем на исходник метода groupBy(), который находится в файле Illuminate/Support/Collection.php

Ну вот и дошли до вывода меню во фронте - и опять рекурсия, ибо без неё никак. Код вывода на первый взгляд может выглядеть несколько запутанным, однако если убрать всю разметку, то станет понятно, что по сути мы отталкиваемся от довольно простой функции (допустим, для наглядности выводим дочерние пункты с дефисами в начале):

function buildMenu($menuitems, $level = 0)
{
    foreach ($menuitems as $item) {
        if (isset($item->children)) {
            buildMenu($item, $level+1);
        } else {
            echo str_repeat('--', $level) . $item . '\n';
        }
    }
}

 

Теперь вариант с Bootstrap 4:

<!-- Left Side Of Navbar -->
<ul class="navbar-nav mr-auto" id="mainMenu">
    @php
    function buildMenu($items, $parent)
    {
        foreach ($items as $item) {
            if (isset($item->children)) {
            @endphp
                <li class="nav-item">
                    <a href="{{ $item->url }}"
                        class="nav-link"
                        id="hasSub-{{ $item->id }}"
                        data-toggle="collapse"
                        data-target="#subnav-{{ $item->id }}"
                        aria-controls="subnav-{{ $item->id }}"
                        aria-expanded="false"
                    >
                        {{ $item->name }}
                    </a>
                    <ul class="navbar-collapse collapse"
                        id="subnav-{{ $item->id }}"
                        data-parent="#{{ $parent }}"
                        aria-labelledby="hasSub-{{ $item->id }}"
                    >
                        @php buildMenu($item->children, 'subnav-'.$item->id) @endphp
                    </ul>
                </li>
            @php
            } else {
            @endphp
                <li class="nav-item">
                    <a href="{{ $item->url }}" class="nav-link">{{ $item->name }}</a>
                </li>
            @php
            }
        }
    }

    buildMenu($menuitems, 'mainMenu')
    @endphp
</ul>

 

И напоследок добавим стили, чтобы меню работало как положено и при этом оставалось адаптивным. Не забудем установить нужные зависимости, для чего выполним в консоли команду:

npm install

 

Открываем файл resources/assets/sass/app.scss и добавляем следующий код:

@import url("https://fonts.googleapis.com/css?family=Raleway:300,400,600");
@import "variables";
@import '~bootstrap/scss/bootstrap';

.navbar-laravel {
  background-color: #fff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);

  ul {
    list-style-type: none;
  }

  .nav-item {
    position: relative;
  }
}

@media (min-width: 768px) {
    .navbar-collapse .nav-item {
        .navbar-collapse {
            display: none !important;
            position: absolute;
            top: 100%;
            left: 0;
            flex-direction: column;
            width: 120px;
            padding: 5px;
            z-index: 10000;
            background-color: #fff;
            border: 1px solid rgba(0,0,0,.125);

            .navbar-collapse {
                top: 0;
                left: 100%;
            }
        }

        &:hover > .navbar-collapse {
            display: flex !important;
        }
    }
}

 

Заключение

Как и задумывалось, конечным результатом является адаптивное меню с неограниченным уровнем вложенности, с заложенной возможностью сортировки и снятия пунктов с публикации.