Магазин на Laravel 7, часть 15. Панель управления, добавление и редактирование брендов

22.10.2020

Теги: LaravelMySQLPHPWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровПанельУправленияПрактикаФреймворкШаблонСайта

Чтобы закончить с брендами, осталось создать два шаблона create.blade.php и edit.blade.php. Поскольку формы для создания и редактирования бренда практически одинаковые — создадим отдельный шаблон form.blade.php — как это делали для категорий. И еще — поскольку мы используем «mass assignment», нужно добавить свойство $fillable в модель Brand.

class Brand extends Model {

    protected $fillable = [
        'name',
        'slug',
        'content',
        'image',
    ];
    /* ... */
}
@extends('layout.admin', ['title' => 'Создание нового бренда'])

@section('content')
    <h1>Создание нового бренда</h1>
    <form method="post" action="{{ route('admin.brand.store') }}" enctype="multipart/form-data">
        @include('admin.brand.part.form')
    </form>
@endsection
@extends('layout.admin', ['title' => 'Редактирование бренда'])

@section('content')
    <h1>Редактирование бренда</h1>
    <form method="post" enctype="multipart/form-data"
          action="{{ route('admin.brand.update', ['brand' => $brand->id]) }}">
        @method('PUT')
        @include('admin.brand.part.form')
    </form>
@endsection
@csrf
<div class="form-group">
    <input type="text" class="form-control" name="name" placeholder="Наименование"
           required maxlength="100" value="{{ old('name') ?? $brand->name ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
           required maxlength="100" value="{{ old('slug') ?? $brand->slug ?? '' }}">
</div>
<div class="form-group">
    <textarea class="form-control" name="content" placeholder="Краткое описание" maxlength="200"
              rows="3">{{ old('content') ?? $brand->content ?? '' }}</textarea>
</div>
<div class="form-group">
    <input type="file" class="form-control-file" name="image" accept="image/png, image/jpeg">
</div>
@isset($brand->image)
    <div class="form-group form-check">
        <input type="checkbox" class="form-check-input" name="remove" id="remove">
        <label class="form-check-label" for="remove">
            Удалить загруженное изображение
        </label>
    </div>
@endisset
<div class="form-group">
    <button type="submit" class="btn btn-primary">Сохранить</button>
</div>

Валидация данных

У нас много общих правил валидации для категории, бренда, товара. Давайте создадим класс CatalogRequest, который будет родителем для CategoryCatalogRequest, BrandCatalogRequest и ProductCatalogRequest:

<?php

namespace App\Http\Requests;

use App\Rules\CategoryParent;
use Illuminate\Foundation\Http\FormRequest;

abstract class CatalogRequest extends FormRequest {

    /**
     * С какой сущностью сейчас работаем: категория, бренд, товар
     * @var array
     */
    protected $entity = [];

    public function authorize() {
        return true;
    }

    public function rules() {
        switch ($this->method()) {
            case 'POST':
                return $this->createItem();
            case 'PUT':
            case 'PATCH':
                return $this->updateItem();
        }
    }

    /**
     * Задает дефолтные правила для проверки данных при добавлении
     * категории, бренда или товара
     */
    protected function createItem() {
        return [
            'name' => [
                'required',
                'max:100',
            ],
            'slug' => [
                'required',
                'max:100',
                'unique:'.$this->entity['table'].',slug',
                'regex:~^[-_a-z0-9]+$~i',
            ],
            'image' => [
                'mimes:jpeg,jpg,png',
                'max:5000'
            ],
        ];
    }

    /**
     * Задает дефолтные правила для проверки данных при обновлении
     * категории, бренда или товара
     */
    protected function updateItem() {
        // получаем объект модели из маршрута: admin/entity/{entity}
        $model = $this->route($this->entity['name']);
        return [
            'name' => [
                'required',
                'max:100',
            ],
            'slug' => [
                'required',
                'max:100',
                // проверка на уникальность slug, исключая эту сущность по идентифкатору
                'unique:'.$this->entity['table'].',slug,'.$model->id.',id',
                'regex:~^[-_a-z0-9]+$~i',
            ],
            'image' => [
                'mimes:jpeg,jpg,png',
                'max:5000'
            ],
        ];
    }
}

Метод createItem() возвращает правила валидации, общие для добавления-редактирования категории, бренда и товара. Метод updateItem() возвращает правила валидации, общие для обновления категории, бренда и товара. А в дочерних классах будем эти правила дополнять или переопределять.

namespace App\Http\Requests;

use App\Rules\CategoryParent;

class CategoryCatalogRequest extends CatalogRequest {

    /**
     * С какой сущностью сейчас работаем (категория каталога)
     * @var array
     */
    protected $entity = [
        'name' => 'category',
        'table' => 'categories'
    ];

    public function authorize() {
        return parent::authorize();
    }

    public function rules() {
        return parent::rules();
    }

    /**
     * Объединяет дефолтные правила и правила, специфичные для категории
     * для проверки данных при добавлении новой категории
     */
    protected function createItem() {
        $rules = [
            'parent_id' => [
                'required',
                'regex:~^[0-9]+$~',
            ],
        ];
        return array_merge(parent::createItem(), $rules);
    }

    /**
     * Объединяет дефолтные правила и правила, специфичные для категории
     * для проверки данных при обновлении существующей категории
     */
    protected function updateItem() {
        // получаем объект модели категории из маршрута: admin/category/{category}
        $model = $this->route('category');
        $rules = [
            'parent_id' => [
                'required',
                'regex:~^[0-9]+$~',
                // задаем правило, чтобы категорию нельзя было поместить внутрь себя
                new CategoryParent($model)
            ],
        ];
        return array_merge(parent::updateItem(), $rules);
    }
}

У класса-потомка тоже есть методы createItem() и updateItem(), где можно добавить новые правила и переопределить дефолтные. Теперь нам нужен класс для проверки данных добавления-редактирования бренда каталога.

> php artisan make:request BrandCatalogRequest
namespace App\Http\Requests;

class BrandCatalogRequest extends CatalogRequest {

    /**
     * С какой сущностью сейчас работаем (бренд каталога)
     * @var array
     */
    protected $entity = [
        'name' => 'brand',
        'table' => 'brands'
    ];

    public function authorize() {
        return parent::authorize();
    }

    public function rules() {
        return parent::rules();
    }

    /**
     * Объединяет дефолтные правила и правила, специфичные для бренда
     * для проверки данных при добавлении нового бренда
     */
    protected function createItem() {
        $rules = [];
        return array_merge(parent::createItem(), $rules);
    }

    /**
     * Объединяет дефолтные правила и правила, специфичные для бренда
     * для проверки данных при обновлении существующего бренда
     */
    protected function updateItem() {
        $rules = [];
        return array_merge(parent::updateItem(), $rules);
    }
}

Как видите, для бренда не пришлось добавлять и переопределять правила — вполне подошли те, которые определены в CatalogRequest. Осталось только изменить «type hinting» для параметра $request в контроллере BrandController:

namespace App\Http\Controllers\Admin;

use App\Helpers\ImageSaver;
use App\Http\Controllers\Controller;
use App\Http\Requests\BrandCatalogRequest;
use App\Models\Brand;

class BrandController extends Controller {
    /* ... */
    public function store(BrandCatalogRequest $request) {
        /* ... */
    }
    /* ... */
    public function update(BrandCatalogRequest $request, Brand $brand) {
        /* ... */
    }
    /* ... */
}

Для определения того, что сущность создается или обновляется, мы используем метод method() и сравниваем полученное значение с POST, PUT, PATCH. Но мы можем пойти другим путем — получить имя маршрута и сравнивать его с admin.item.create, admin.item.update.

class ItemRequest extends FormRequest {
    public function rules() {
        switch ($this->route()->getName()) {
            case 'admin.item.create':
                return $this->createItem();
            case 'admin.item.update':
                return $this->updateItem();
        }
    }
}

Работа над ошибками

1. Меню каталога в левой колонке

Не дает мне покоя меню каталога в левой колонке в публичной части сайта. Мы показываем только два уровня каталога, хотя их может быть три, четыре или пять. Давайте доработаем меню, чтобы показывать все уровни каталога, независимо от того, сколько их всего. Для этого создадим еще один шаблон branch.blade.php в директории views/layout/part.

<ul>
@foreach ($items as $item)
    <li>
        <a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{ $item->name }}</a>
        @if ($item->children->count())
            <span class="badge badge-dark">
                <i class="fa fa-plus"></i>
            </span>
            @include('layout.part.branch', ['items' => $item->children])
        @endif
    </li>
@endforeach
</ul>

Тогда шаблон views/layout/part/tree.blade.php будет совсем простым:

<h4>Разделы каталога</h4>
<div id="catalog-sidebar">
    @include('layout.part.branch', ['items' => $items])
</div>

Но теперь мы опять столкнулись с проблемой большого количества запросов к базе данных:

SELECT * FROM `categories` WHERE `parent_id` = 0
SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4)
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 5
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 8
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 6
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 7
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 9
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 10
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 11
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 12

Давайте это исправим — для этого создадим еще два метода в классе модели:

class Category extends Model {
    /**
     * Связь «один ко многим» таблицы `categories` с таблицей `categories`, но
     * позволяет получить не только дочерние категории, но и дочерние-дочерние
     */
    public function descendants() {
        return $this->hasMany(Category::class, 'parent_id')->with('descendants');
    }
    
    /**
     * Возвращает список всех категорий каталога в виде дерева
     */
    public static function hierarchy() {
        return self::where('parent_id', 0)->with('descendants')->get();
    }
}

Внесем исправления в шаблоны, чтобы обращаться к виртуальному свойству descendants всместо children:

<ul>
@foreach($items as $item)
    <li>
        <a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{ $item->name }}</a>
        @if ($item->descendants->count())
            <span class="badge badge-dark">
                <i class="fa fa-plus"></i>
            </span>
            @include('layout.part.branch', ['items' => $item->descendants])
        @endif
    </li>
@endforeach
</ul>

И в классе ComposerServiceProvider будем получать категории каталога для меню от метода hierarchy():

class ComposerServiceProvider extends ServiceProvider {
    /* ... */
    public function boot() {
        View::composer('layout.part.roots', function($view) {
            $view->with(['items' => Category::hierarchy()]);
        });
        /* ... */
    }
}
SELECT * FROM `categories` WHERE `parent_id` = 0
SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4)
SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (5, 6, 7, 9, 10)
SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (8, 11, 12)

Получилось уже намного лучше, но есть еще один способ, позволяющий обойтись всего одним запросом. Мы получаем все категории с помощью метода all() модели, а в шаблонах на каждом уровне отбираем только нужные.

class ComposerServiceProvider extends ServiceProvider {
    /* ... */
    public function boot() {
        View::composer('layout.part.roots', function($view) {
            $view->with(['items' => Category::all()]);
        });
        /* ... */
    }
}
<h4>Разделы каталога</h4>
<div id="catalog-sidebar">
    @include('layout.part.branch', ['parent' => 0])
</div>
<ul>
@foreach ($items->where('parent_id', $parent) as $item)
    <li>
        <a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{ $item->name }}</a>
        @if (count($items->where('parent_id', $item->id)))
            <span class="badge badge-dark">
                <i class="fa fa-plus"></i>
            </span>
            @include('layout.part.branch', ['parent' => $item->id])
        @endif
    </li>
@endforeach
</ul>

Переменная $items, которая содержит все категории каталога, доступна всегда в шаблоне branch.blade.php, потому что передается из родительского шаблона. И мы всегда передаем в шаблон branch.blade.php переменную $parent, которая содержит идентификатор родителя. Эту переменную мы используем, чтобы с помощью метода where() отобрать только категории текущего уровня.

SELECT * FROM `categories`

2. Иерархия категорий в панели управления

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

class CategoryController extends Controller {
    /* ... */
    public function index() {
        $items = Category::all();
        return view('admin.category.index', compact('items'));
    }
    /* ... */
    public function create() {
        // все категории для возможности выбора родителя
        $items = Category::all();
        return view('admin.category.create', compact('items'));
    }
    /* ... */
    public function edit(Category $category) {
        // все категории для возможности выбора родителя
        $items = Category::all();
        return view('admin.category.edit', compact('category', 'items'));
    }
    /* ... */
}

Шаблон views/admin/category/index.blade.php:

@extends('layout.admin', ['title' => 'Все категории каталога'])

@section('content')
    <h1>Все категории</h1>
    <a href="{{ route('admin.category.create') }}" class="btn btn-success mb-4">
        Создать категорию
    </a>
    <table class="table table-bordered">
        <tr>
            <!-- ..... -->
        </tr>
        @include('admin.category.part.tree', ['level' => -1, 'parent' => 0])
    </table>
@endsection

Шаблон views/admin/category/part/tree.blade.php:

@php $level++ @endphp
@foreach ($items->where('parent_id', $parent) as $item)
    <tr>
        <!-- ..... -->
    </tr>
    @if (count($items->where('parent_id', $parent)))
        @include('admin.category.part.tree', ['level' => $level, 'parent' => $item->id])
    @endif
@endforeach

Шаблон views/admin/category/part/form.blade.php:

<!-- ..... -->
<div class="form-group">
    @php
        $parent_id = old('parent_id') ?? $category->parent_id ?? 0;
    @endphp
    <select name="parent_id" class="form-control" title="Родитель">
        <option value="0">Без родителя</option>
        @if (count($items))
            @include('admin.category.part.branch', ['level' => -1, 'parent' => 0])
        @endif
    </select>
</div>
<!-- ..... -->

Шаблон views/admin/category/part/branch.blade.php:

@php $level++ @endphp
@foreach ($items->where('parent_id', $parent) as $item)
    <option value="{{ $item->id }}" @if ($item->id == $parent_id) selected @endif>
        @if ($level) {!! str_repeat('&nbsp;&nbsp;&nbsp;', $level) !!}  @endif {{ $item->name }}
    </option>
    @if (count($items->where('parent_id', $parent)))
        @include('admin.category.part.branch', ['level' => $level, 'parent' => $item->id])
    @endif
@endforeach

Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Интернет магазин • Каталог товаров • Панель управления • Практика • Фреймворк • Шаблон сайта

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.