На прошлом уроке мы доработали наш вывод товаров, добавив к нему выборку картинок.
Теперь нужно сделать вывод категорий и страниц товаров. Учитывая общепринятые нормы, мы не можем выводить их все просто по id
, поэтому давайте добавим колонки alias
обеим моделям.
У категорий все alias
будут уникальны, а у товаров они будут уникальны в каждой категории.
Alias
становится обязательным полем для модели, то есть он всегда должен быть заполнен. Это значит, что колонка в таблице не будет nullable
, а поэтому при создании уникального индекса по этой колонки в ней уже должны быть уникальные значения, или получим ошибку.
Если бы наш магазин уже был в работе, то нам нужно было бы добавить новую колонку, затем прописать уникальные значения в каждую модель, и только потом добавлять индекс unique()
. Но сейчас мы можем смело удалить все записи из БД, а после запуска миграции создать их снова через запуск seed
.
Создаём новую миграцию composer db:create Aliases
и редактируем:
<?php
declare(strict_types=1);
use App\Models\Category;
use App\Models\Product;
use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Eloquent;
use Vesp\Services\Migration;
final class Aliases extends Migration
{
public function up(): void
{
// Отключаем проверку связей с другими таблицами
(new Eloquent())->getConnection()->getSchemaBuilder()->disableForeignKeyConstraints();
// И удаляем все товары с категориями
Category::query()->truncate();
Product::query()->truncate();
// Меняем таблицу категорий
$this->schema->table(
'categories',
function (Blueprint $table) {
// И добавляем уникальную колонку alias сразу за колонкой description
$table->string('alias')->after('description')->unique();
}
);
// Затем меняем и таблицу товаров
$this->schema->table(
'products',
function (Blueprint $table) {
// Добавляем новую колонку
$table->string('alias')->after('description');
// И отдельно составной уникальный индекс
$table->unique(['category_id', 'alias']);
}
);
}
// При откате миграции нужно привести всё к виду "как было до неё"
public function down(): void
{
$this->schema->table(
'products',
function (Blueprint $table) {
// Новый составной уникальный индекс у товаров расширяет старый ключ по категориям
// Поэтому удаляем и ключ
$table->dropForeign(['category_id']);
// И уникальный индекс, как будто их и не было
$table->dropUnique(['category_id', 'alias']);
// Удаляем новую колонку
$table->dropColumn('alias');
// И создаём заново ключ по категориям
$table->foreign('category_id')->references('id')
->on('categories')->cascadeOnDelete();
}
);
// У категорий же всё проще
$this->schema->table(
'categories',
function (Blueprint $table) {
// Просто удаляем колонку, при этом удалится и её индекс
$table->dropColumn('alias');
}
);
}
}
Правильностью работы миграции я считаю возможность запустить migrate
, а затем rollback
и снова migrate
без ошибок. Это будет означать, что миграция правильно откатывает все свои изменения.
Так что делаем
composer db:migrate
composer db:rollback
composer db:migrate
И видим, что всё в порядке.
Теперь нужно добавить alias
в наш seed товаров - редактируем core/db/seeds/Products.php
:
<?php
// ...
class Products extends AbstractSeed
{
// ...
public function run(): void
{
// ...
for ($i = 0; $i < $this->categories; $i++) {
Category::query()->create([
'title' => 'Категория ' . ($i + 1),
// Псевдоним категории
'alias' => 'category-' . ($i + 1),
// ...
]);
}
for ($i = 0; $i < $this->products; $i++) {
Product::query()->create([
'title' => 'Товар ' . ($i + 1),
'description' => $faker->text,
// Псевдоним товара
'alias' => 'product-' . ($i + 1),
// ...
]);
}
}
}
Файлик привожу в сокращённом виде, тут всё понятно - просто забиваем значение в новую колонку.
В моделях Category
и Product
ничего менять не нужно, только прописать новое свойство для автодополнения в IDE:
* @property string $alias
Тут тоже всё просто - добавляем новое поле в формы frontend/src/admin/components/forms/category.vue
:
<b-form-group :label="$t('models.category.alias')">
<b-form-input v-model.trim="record.alias" required />
</b-form-group>
и frontend/src/admin/components/forms/product.vue
:
<b-form-group :label="$t('models.product.alias')">
<b-form-input v-model.trim="record.alias" required />
</b-form-group>
А затем соответствующие записи в словарях админки frontend/src/admin/lexicons
для категории и товара:
alias: 'Псевдоним',
И всё у нас работает:
Уникальность псевдонима сейчас проверяется на стороне базы данных, поэтому если мы укажем неуникальный псевдоним для категории, получим вот такую некрасивую ошибку:
Поэтому я предлагаю нам самим проверять уникальность псевдонима в контроллере core/src/Controllers/Admin/Categories.php
в методе beforeSave()
, который и предназначен для подобных проверок:
protected function beforeSave(Model $record): ?ResponseInterface
{
// Создаём новый запрос в БД, с указанным alias модели
$c = Category::query()->where('alias', $record->alias);
// Если эта модель уже существует, то есть это не создание, а редактирование
// Добавляем еще проверку по id
if ($record->exists) {
$c->where('id', '!=', $record->id);
}
// И если в БД уже есть модель с другим id, но таким же alias
if ($c->count()) {
// Возвращаем ошибку в виде ключа из лексиконов
return $this->failure('errors.category.alias_exists');
}
return null;
}
Остаётся только добавить соответствующие сообщения в наши словари frontend/src/admin/lexicons
:
// ...
errors: {
// ...
category: {
alias_exists: 'Такой псевдоним уже существует, укажите другой',
}
},
Проверяем наши изменения:
По большому счёту мы просто украсили уже работающий механизм проверки уникальности колонки на стороне базы данных. Ровно так же нужно прописать и проверку для товаров, только с указанием id
категории.
Редактируем core/src/Controllers/Admin/Products.php
:
protected function beforeSave(Model $record): ?ResponseInterface
{
// Вот здесь добавляется второе условие
$c = Product::query()->where(['alias' => $record->alias, 'category_id' => $record->category_id]);
if ($record->exists) {
$c->where('id', '!=', $record->id);
}
if ($c->count()) {
// Ну и меняется ключ ошибки
return $this->failure('errors.product.alias_exists');
}
return null;
}
И не забываем обновить словари с указанием новой ошибки для товаров.
Так как мы делаем ненастоящий магазин в целях обучения, я предлагаю не заморачиваться по с отдельным выводом категорий, общего меню сайта и прочего.
Мы оставляем тот же общий вывод товаров на главной странице, но приделываем для каждого товара собственную страницу, которая будет внутри страницы его категории.
Структура получается такая:
Главная со всеми товарами
- Страница категории товара, с выводом всех её товаров
- Страница конкретного товара
Согласно правилам роутинга NuxtJs получается такая структура файлов:
pages/
_category/
_product/
index.vue - динамическая страница товара
index.vue - динамическая страница категории
index.vue - главная страница
Указание имён каталогов с ведущим подчёркиванием делает их динамическими. Давайте пропишем в 2х новых файлах index.vue
такой код для отладки:
<template>
<div>{{ $route.params }}</div>
</template>
Теперь при переходе по любым адресам до 2х уровней вложенности мы будем видеть рабочие страницы и переданные в URL параметры:
Теперь нам нужно доработать эти страницы так, чтобы категории выводили свои товары, а страница товаров - данные о нём. Сегодня разбираем принцип работы, пока без оформления.
Раз наши категории и товары теперь будут запрашиваться не по id
, расширяем метод определения первичного ключа в публичном контроллере категории core/src/Controllers/Web/Categories.php
. У категорий alias
уникален и будет заменять id
:
protected function getPrimaryKey(): ?array
{
// Если указан параметр alias, то это запрос одной модели
if ($alias = $this->getProperty('alias')) {
// И мы возвращаем массив, по которому эту модель можно выбрать
return ['alias' => $alias];
}
return null;
}
А вот с товарами ситуация сложнее, ведь у них alias
уникален только в пределах одной категории, значит нам обязательно нужно передавать и её alias
при запросе.
Я предлагаю разбить функционал текущего контроллера товаров на 2 контроллера.
Первый Controllers/Web/Products.php
оставляем на месте и вносим всего 2 изменения
protected function afterCount(Builder $c): Builder
{
// Добавляем категориям выборку их alias
$c->with('category:id,title,alias');
$c->with('firstFile');
return $c;
}
// Первичный ключ всегда null, а значит работать будет только вывод товаров списком
protected function getPrimaryKey(): ?array
{
return null;
}
А для вывода товаров категории, как списком, так и поштучно, создаём новый контроллер по адресу core/src/Controllers/Web/Category/Products.php
:
<?php
namespace App\Controllers\Web\Category;
use App\Models\Category;
use Illuminate\Database\Eloquent\Builder;
use Psr\Http\Message\ResponseInterface;
// Новый контроллер расширяет общий контроллер товаров
class Products extends \App\Controllers\Web\Products
{
/** @var Category $category */
protected $category;
// Загружаем категорию, так же как в админке у контроллера файлов загружали товар
public function checkScope(string $method): ?ResponseInterface
{
// Категория должна быть активна
$c = Category::query()->where(['active' => true, 'alias' => $this->getProperty('category')]);
if (!$this->category = $c->first()) {
return $this->failure('', 404);
}
return null;
}
protected function beforeGet(Builder $c): Builder
{
// Все получаемые товары должны быть активны
$c->where('active', true);
$c->with('category:id,title,alias');
return $c;
}
protected function beforeCount(Builder $c): Builder
{
// Список моделей выбирается только для указанной категории
$c->where(['active' => true, 'category_id' => $this->category->id]);
return $c;
}
protected function getPrimaryKey(): ?array
{
// Если указан alias товара, то это запрос одной модели, и мы выбираем её с учётом
// загруженной категории
if ($alias = $this->getProperty('alias')) {
return ['alias' => $alias, 'category_id' => $this->category->id];
}
return null;
}
}
Мы расширяем основной контроллер товаров и заменяем нужные нам методы.
И затем обновляем наши публичные маршруты core/routes.php
, чтобы передавать в них правильные параметры для контроллеров:
$group->group(
'/web',
static function (RouteCollectorProxy $group) {
// Вместо id теперь alias
$group->any('/categories[/{alias}]', App\Controllers\Web\Categories::class);
// Новый маршрут для выборки товаров категории
// Как списком, так и поштучно
$group->any('/categories/{category}/products[/{alias}]', App\Controllers\Web\Category\Products::class);
// Оставляем общую выборку товаров, но без возможности получаения отдельной позиции
$group->any('/products', App\Controllers\Web\Products::class);
}
);
Теперь для получения данных одного товара нам обязательно нужно обращаться в его категорию. Выборки по id
больше не работают, только по alias
. При этом осталась возможность получить все активные товары из опубликованных категорий, одним большим списком для главной страницы.
Проверяем новые адреса:
api/web/categories
выводит список всех активных категорий
api/web/categories/alias-категории
выводит саму категорию
api/web/categories/alias-категории/products
выводит список товаров категории
api/web/categories/alias-категории/products/alias-товара
выведет один товар категории
Всё просто и логично. Осталось только вывести эти данные на фронтенде.
Первым делом проставляем ссылки на страницу товара в нашем компоненте frontend/src/site/components/list-products.vue
. Для этого мы параметры ссылки выносим в отдельный метод getLink()
, который используется при оформлении:
<template>
<b-overlay :show="loading" opacity="0.5">
<!-- -->
<b-link :to="getLink(product)">
<div class="image mr-2">
<!-- -->
</div>
</b-link>
<!-- -->
<b-link :to="getLink(product)" class="font-weight-bold">{{ product.title }}</b-link>
<!-- -->
</b-overlay>
</template>
<script>
//...
methods: {
getLink(product) {
return {name: 'category-product', params: {category: product.category.alias, product: product.alias}}
},
},
}
Использование отдельного метода для ссылок гораздо удобнее, чем прописывать их в шаблоне в каждом нужно месте. Тем более, что ссылки могут иногда меняться.
Теперь при клике на картинку или название товара мы попадём на его страницу, где нам нужно будет загрузить данные. Редактируем frontend/src/site/pages/_category/_product/index.vue
:
<template>
<div class="mt-5">
<!-- Ссылка на главную страницу-->
<b-link :to="{name: 'index'}">← Вернуться назад</b-link>
<!-- Пока что просто печатаем загруженные данные -->
<pre class="mt-3">{{ product }}</pre>
</div>
</template>
<script>
export default {
name: 'ProductPage',
// Проверяем, чтобы были присланы alias категории и товара
validate({params}) {
return params.category && params.product
},
// И получаем данные с сервера
async asyncData({app, params, error}) {
try {
// Формируем правильный адрес до коннектора
const {data} = await app.$axios.get('web/categories/' + params.category + '/products/' + params.product)
// Выставляем полученные данные на страницу
return {product: data}
} catch (e) {
error({statusCode: e.response.status, message: e.message})
}
},
data() {
return {
product: {},
}
},
}
</script>
Проверяем результат (клик на GIFку):
А если вручную указать неправильную ссылку в адресе - будет стандартная ошибка Nuxt.
Чтобы правильно выводить текст ошибки измените frontend/src/site/nuxt.config.js
таким образом:
Config.Vesp = {
components: false,
scss: false,
i18n: false,
axios: false, // Вот здесь нужно поменять на false
utils: true,
filters: false,
}
Этим мы отключим плагин Vesp для обработки ошибок, который рассчитывает на всякие, на данный момент, у нас отключенные штуки. Надо бы мне его доработать...
Вот мы и вывели наши товары, пока что без оформления, но как вы понимаете - это дело техники. Добавляем нужные данные для выборки в контроллере и оформляем на странице, ничего сложного.
На следующем занятии мы этим и займёмся, вместе со страницей категории. Все изменения этого урока можно найти на Github.
Задам несколько вопросов, пока не "набежали" более опытные товарищи)):
Да, конечно, можно поискать и подключить нужную библиотеку. Я обычно для такого использую Slugify. Вот, написал компонент для ввода alias за 10 минут.
Просто хотелось показать небольшую разницу в подходах между категориями и товарами. На реальном проекте всё зависит от логики разработчика. Можно генерировать
alias
и автоматически.У нас - нет. Но, опять же, это всегда можно дописать. Помнишь, как мы связали товары с файлами через отдельную таблицу с составным ключом? Точно так же можно связать и товары с разными категориями.
Но на практике такое крайне редко нужно, обычно у товара только одна категория - и один уникальный адрес. В miniShop2 это встроено по умолчанию из-за его универсальности, а нам это не нужно, мы и не делаем.
Просто разный подход к разработке.
Василий добрый день!
У меня две проблемы и как-будто они между собой связаны.
1. Не выводится json по ссылке: http://vesp-shop.test/api/web/categories/category-2/products/product-113 пишет:
"Could not find a record"
, хотя http://vesp-shop.test/api/web/categories/category-2/products - все в порядке:
Также все в порядке по ссылкам http://vesp-shop.test/api/web/categories/category-2 и http://vesp-shop.test/api/web/categories .
2. Запустив
composer node:dev
я меняю домен в ссылке с vesp-shop.test на 192.168.8.110:4100 и по ссылке: http://192.168.8.110:4100/api/web/categories/category-2/products вижу 404 от Nuxt.Далее я запускаю
composer node:generate
, перехожу на http://vesp-shop.test/api/web/categories/category-2/products и вижу рабочую страницу с json.И с другими ссылками (http:// *** /api/web/categories/category-2 и http:// *** /api/web/categories та же история на vesp-shop.test работает, а на 192.168.8.110:4100 ошибка 404 и
composer node:generate
ситуацию не меняет, запускал команду много раз.Василий, может у тебя есть, какие-нибудь идеи, куда мне можно посмотреть?
Нет, не всё, судя по картинке. У тебя же там alias =
category-113
, а неproduct-113
, его и нужно указывать.Конечно, Nuxt не обслуживает запросы в API, это работа для PHP сервера, который работает на vesp-shop.test.
Так что никаких ошибок у тебя нет.
Да, точно, что-то не обратил на это внимание.
Спасибо, за разъяснение, а то меня эта ситуация тяготила.
На здоровье!
Почему может не работать метод getLink? не могу понять напрямую /category-2/product-113 открывается норм а на главной у товаров просто показывает ссылку на /
Решил
Пиши тогда, в чем у тебя была проблема.
невнимательность
alias не вставил
Понял, спасибо!
У меня после создания миграции создается файл - core/db/migrations/20230521075426.php. Судя по коду, к которому, как ты пишешь, нужно затем привести этот файл - у тебя в Github этот файл называется core/db/migrations/20220628050120_aliases.php.
Все последующие команды у меня потом выдают кучу ошибок, видимо намекающих, что название моего файла не правильное.
Например после composer db:rollback выдается:
Видимо файл, который у меня создается после выполнения composer db:create также, как и у тебя должен называться 20220628050120_aliases.php . И поскольку ты ничего не пишешь, что этот файл нужно вручную переименовывать, значит он должен сразу создаваться с правильным названием. Но у меня почему-то это не так. Возможно ранее какие-то ошибки в файлах допустил. А так, все описанное ранее у меня получилось.
Присоздании миграции нужно указывать для неё имя:
Так получилось) Спасибо!
Т.е. если у меня после выполнения composer db:migrate в админке и на фронте пропали все товары категории и картинки - значит у меня что-то было в файлах неправильно?
Миграция просто выполняет какие-то изменения.
Какие именно ты выполнил изменения, что сразу всё пропало, я не знаю. Судя по твоим вопросам, может быть вообще что угодно.
да, собственно, выполнил 3 шага:
Вот и все изменения. После этого все пропало. Т.е. все текущие шаги выполнил вроде как ты пишешь. Значит данные могли пропасть из-за каких-то ошибок, которые были ранее. Буду разбираться.
Слушай, ну прямо в заметке же написано
И в коде самой миграции указано очистить таблицы с категориями и товарами.
// Отключаем проверку связей с другими таблицами (new Eloquent())->getConnection()->getSchemaBuilder()->disableForeignKeyConstraints(); // И удаляем все товары с категориями Category::query()->truncate(); Product::query()->truncate();
Как ты так умудряешься уроки проходить, не читая текст?
Да, теперь появились! Спасибо за твое терпение!