Багаторівневе меню в 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;
        }
    }
}

 

Висновок

Як і було задумано, кінцевим результатом є адаптивне меню з необмеженим рівнем вкладеності, із закладеною можливістю сортування і зняття пунктів з публікації.