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

13.10.2020

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

Методы create() и edit()

Реализуем еще два метода контроллера — create() и edit() — для создания новой категории каталога и для редактирования существующей.

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

Два новых шаблона

И создадим два шаблона в директории views/admin/category — это файлы create.blade.php и edit.blade.php.

@extends('layout.admin')

@section('content')
    <h1>Создание новой категории</h1>
    <form method="post" action="{{ route('admin.category.store') }}" enctype="multipart/form-data">
        @csrf
        <div class="form-group">
            <input type="text" class="form-control" name="name" placeholder="Наименование"
                   required maxlength="100" value="{{ old('name') ?? '' }}">
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
                   required maxlength="100" value="{{ old('slug') ?? '' }}">
        </div>
        <div class="form-group">
            <select name="parent_id" class="form-control" title="Родитель">
                <option value="0">Без родителя</option>
                @if (count($parents))
                    @include('admin.category.part.branch', ['items' => $parents, 'level' => -1])
                @endif
            </select>
        </div>
        <div class="form-group">
            <textarea class="form-control" name="content" placeholder="Краткое описание"
                      maxlength="200" rows="3">{{ old('content') ?? '' }}</textarea>
        </div>
        <div class="form-group">
            <input type="file" class="form-control-file" name="image" accept="image/png, image/jpeg">
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-primary">Сохранить</button>
        </div>
    </form>
@endsection
@extends('layout.admin')

@section('content')
    <h1>Редактирование категории</h1>
    <form method="post" enctype="multipart/form-data"
          action="{{ route('admin.category.update', ['category' => $category->id]) }}">
        @method('PUT')
        @csrf
        <div class="form-group">
            <input type="text" class="form-control" name="name" placeholder="Наименование"
                   required maxlength="100" value="{{ old('name') ?? $category->name }}">
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="title" placeholder="ЧПУ (на англ.)"
                   required maxlength="100" value="{{ old('slug') ?? $category->slug }}">
        </div>
        <div class="form-group">
            <select name="parent_id" class="form-control" title="Родитель">
                <option value="0">Без родителя</option>
                @if (count($parents))
                    @include('admin.category.part.branch', ['items' => $parents, 'level' => -1])
                @endif
            </select>
        </div>
        <div class="form-group">
            <textarea class="form-control" name="content" placeholder="Краткое описание"
                      maxlength="200" rows="3">{{ old('content') ?? $category->content }}</textarea>
        </div>
        <div class="form-group">
            <input type="file" class="form-control-file" name="image" accept="image/png, image/jpeg">
        </div>
        @isset($category->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>
    </form>
@endsection

Для показа списка выбора родителя, мы рекурсивно подключаем шаблон views/admin/catgory/part/branch.blade.php:

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

Оптимизация шаблонов

Не слишком удачно, что у нас две почти одинаковые формы в двух шаблонах. Давайте создадим еще один шаблон form.blade.php в директории view/admin/category/part.

@csrf
<div class="form-group">
    <input type="text" class="form-control" name="name" placeholder="Наименование"
           required maxlength="100" value="{{ old('name') ?? $category->name ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
           required maxlength="100" value="{{ old('slug') ?? $category->slug ?? '' }}">
</div>
<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($parents))
            @include('admin.category.part.branch', ['items' => $parents, 'level' => -1])
        @endif
    </select>
</div>
<div class="form-group">
    <textarea class="form-control" name="content" placeholder="Краткое описание"
              maxlength="200" rows="3">{{ old('content') ?? $category->content ?? '' }}</textarea>
</div>
<div class="form-group">
    <input type="file" class="form-control-file" name="image" accept="image/png, image/jpeg">
</div>
@isset($category->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>
@php $level++ @endphp
@foreach($items 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 ($item->children->count())
        @include('admin.category.part.branch', ['items' => $item->children, 'level' => $level])
    @endif
@endforeach

Тогда два шаблона для создания новой категории и редактирования существующей будут совсем маленькими:

@extends('layout.admin')

@section('content')
    <h1>Создание новой категории</h1>
    <form method="post" action="{{ route('admin.category.store') }}" enctype="multipart/form-data">
        @include('admin.category.part.form')
    </form>
@endsection

@extends('layout.admin')

@section('content')
    <h1>Редактирование категории</h1>
    <form method="post" enctype="multipart/form-data"
          action="{{ route('admin.category.update', ['category' => $category->id]) }}">
        @method('PUT')
        @include('admin.category.part.form')
    </form>
@endsection

Методы store() и update()

Поскольку мы планируем использовать «mass assignment», нужно добавить свойство $fillable в модель Category:

class Category extends Model {

    protected $fillable = [
        'parent_id',
        'name',
        'slug',
        'content',
        'image',
    ];
    /* ... */
}

А методы store() и update() будут достаточно простыми, пока не займемся загрузкой и обрезкой изображений.

class CategoryController extends Controller {
    /* ... */
    public function store(Request $request) {
        // проверяем данные формы создания категории
        $this->validate($request, [
            'parent_id' => 'integer',
            'name' => 'required|max:100',
            'slug' => 'required|max:100|unique:categories,slug|alpha_dash',
            'image' => 'mimes:jpeg,jpg,png|max:5000'
        ]);
        // проверка пройдена, сохраняем категорию
        $category = Category::create($request->all());
        return redirect()
            ->route('admin.category.show', ['category' => $category->id])
            ->with('success', 'Новая категория успешно создана');
    }
    /* ... */
    public function update(Request $request, Category $category) {
        // проверяем данные формы редактирования категории
        $id = $category->id;
        $this->validate($request, [
            'parent_id' => 'integer',
            'name' => 'required|max:100',
            /*
             * Проверка на уникальность slug, исключая эту категорию по идентифкатору:
             * 1. categories — таблица базы данных, где проверяется уникальность
             * 2. slug — имя колонки, уникальность значения которой проверяется
             * 3. значение, по которому из проверки исключается запись таблицы БД
             * 4. поле, по которому из проверки исключается запись таблицы БД
             * Для проверки будет использован такой SQL-запрос к базе данныхЖ
             * SELECT COUNT(*) FROM `categories` WHERE `slug` = '...' AND `id` <> 17
             */
            'slug' => 'required|max:100|unique:categories,slug,'.$id.',id|alpha_dash',
            'image' => 'mimes:jpeg,jpg,png|max:5000'
        ]);
        // проверка пройдена, обновляем категорию
        $category->update($request->all());
        return redirect()
            ->route('admin.category.show', ['category' => $category->id])
            ->with('success', 'Категория была успешно исправлена');
    }
    /* ... */
}

Нехорошо заставлять администратора заполнять поле «ЧПУ (англ)» — давайте сами заполнять это поле на основе названия категории. Для этого в файл public/js/admin.js добавляем следующий код.

jQuery(document).ready(function($) {
    $('input[name="name"]').on('input', function() {
        var map = {
            'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', 'Ж': 'Zh',
            'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', 'Н': 'N', 'О': 'O',
            'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', 'Ф': 'F', 'Х': 'H', 'Ц': 'C',
            'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu',
            'Я': 'Ya',
            'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
            'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o',
            'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'c',
            'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu',
            'я': 'ya',
        };
        var text = $(this).val();
        for (var k in map) {
            text = text.replace(RegExp(k, 'g'), map[k]);
        }
        text = text.replace(/[^- _a-zA-Z0-9]/g, '');
        text = text.replace(/\s+/g, '-');
        text = text.replace(/-+/g, '-');
        $('input[name="slug"]').val(text);
    });
});

Небольшое отступление

Прежде, чем двигаться дальше, давайте посмотрим, как в Laravel организовано хранение и загрузка файлов. Это нам потребуется, чтобы организовать загрузку и хранение изображений для категорий, брендов и товаров.

Файловое хранилище

Сразу после установки Laravel доступны диски local и public, использующие драйвер local. Для диска local место хранения — директория storage/app, для диска public место хранения — директория storage/app/public. Диск local является диском по умолчанию, это задается в файле настроек config/filesystems.php.

return [
    /* ... */
    'default' => env('FILESYSTEM_DRIVER', 'local'),
    /* ... */
    'disks' => [
        'local' => [
            'driver' => 'local',
            'root' => storage_path('app'),
        ],
        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
        ],
    ],
    /* ... */
    'links' => [
        public_path('storage') => storage_path('app/public'),
    ],
    /* ... */
];

Чтобы сделать файлы диска public доступными через веб, надо создать символьную ссылку из public/storage на storage/app/public. Директория public проекта Laravel — является корневой директорией сервера, поэтому файл storage/app/public/image.jpg будет доступен через веб как http://server.com/storage/image.jpg.

> php artisan storage:link

Какие символьные ссылки создавать — задается в файле конфигурации, см. выше. Когда файл сохранён на диске и создана символьная ссылка, можно создать URL к файлу с помощью хелпера asset() или метода url() фасада Storage:

<img src="{{ asset('storage/images/image.jpg') }}" alt="" />
<img src="{{ Storage::disk('public')->url('images/image.jpg') }}" alt="" />

При использовании диска local при вызове метода Storage::url() будет возвращен URL вида /storage/images/image.jpg.

При использовании драйвера local все файловые операции выполняются относительно директории root, определенной в конфигурационном файле. Для диска local директория root — это storage/app, для диска public директория root — это storage/app/public.

// файл будет сохранен в storage/app/data/file.txt
Storage::disk('local')->put('data/file.txt', 'Some file content');

Загрузка файлов

В Laravel очень просто сохранять загружаемые файлы методом store() на экземпляре загружаемого файла:

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class UserAvatarController extends Controller {
    /**
     * Обновление аватара пользователя.
     */
    public function update(Request $request) {
        // будет сохранен как storage/app/avatars/L6ceL...xzXFw.jpeg
        $path = $request->file('avatar')->store('avatars');
        return $path;
    }
}

Мы указываем только директорию avatars, а имя файла будет сформировано автоматически. Метод вернёт путь к файлу, поэтому можно сохранить в БД весь путь, включая сгенерированное имя. Файл будет сохранен на диск по умолчанию (и не будет доступен из веб), но можно указать диск вторым аргументом метода store().

// будет сохранен как storage/app/public/avatars/L6ceL...xzXFw.jpeg и будет
// доступен из веб как http://server.com/storage/avatars/L6ceL...xzXFw.jpeg
$path = $request->file('avatar')->store('avatars', 'public');

Чтобы задать свое имя файла и (опционально) диск для сохранения, можно использовать метод storeAs():

// будет использован диск по умолчанию
$path = $request->file('avatar')->storeAs(
    'avatars', // директория, куда сохранять
    $request->user()->id // имя файла
);
// явное указание диска для сохранения
$path = $request->file('avatar')->storeAs(
    'avatars', // директория, куда сохранять
    $request->user()->id, // имя файла
    'public' // диск, куда сохранять
);

Поиск: 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.