Вывод товаров на сайте

На прошлом уроке мы доработали наш вывод товаров, добавив к нему выборку картинок.
Теперь нужно сделать вывод категорий и страниц товаров. Учитывая общепринятые нормы, мы не можем выводить их все просто по 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

Работаем с 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'}">&larr; Вернуться назад</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.

20 комментариев

Задам несколько вопросов, пока не "набежали" более опытные товарищи)):
  1. Есть ли возможность формировать "friendly URL aliases", используя аналог translit MODx?
  2. Почему у товаров alias уникален только в пределах одной категории? Может ли товар отображаться в разных категориях как в minishop2, что в том случае будет с alias товара? .
Василий Наумкин
Есть ли возможность формировать "friendly URL aliases", используя аналог translit MODx?
Да, конечно, можно поискать и подключить нужную библиотеку. Я обычно для такого использую Slugify. Вот, написал компонент для ввода alias за 10 минут.
Почему у товаров alias уникален только в пределах одной категории?
Просто хотелось показать небольшую разницу в подходах между категориями и товарами. На реальном проекте всё зависит от логики разработчика. Можно генерировать alias и автоматически.
Может ли товар отображаться в разных категориях как в minishop2, что в том случае будет с 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 и 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 ситуацию не меняет, запускал команду много раз.
Василий, может у тебя есть, какие-нибудь идеи, куда мне можно посмотреть?
Василий Наумкин
хотя http://vesp-shop.test/api/web/categories/category-2/products - все в порядке:
Нет, не всё, судя по картинке. У тебя же там alias = category-113, а не product-113, его и нужно указывать.
вижу 404 от Nuxt
Конечно, Nuxt не обслуживает запросы в API, это работа для PHP сервера, который работает на vesp-shop.test.
Так что никаких ошибок у тебя нет.
Александр Наумов
Нет, не всё, судя по картинке. У тебя же там alias = category-113, а не product-113, его и нужно указывать.
Да, точно, что-то не обратил на это внимание.
Конечно, Nuxt не обслуживает запросы в API, это работа для PHP сервера, который работает на vesp-shop.test.
Спасибо, за разъяснение, а то меня эта ситуация тяготила.
Василий Наумкин
На здоровье!
Почему может не работать метод getLink? не могу понять напрямую /category-2/product-113 открывается норм а на главной у товаров просто показывает ссылку на /
Решил
Василий Наумкин
Пиши тогда, в чем у тебя была проблема.
невнимательность
Первый Controllers/Web/Products.php оставляем на месте и вносим всего 2 изменения
... $c->with('category:id,title,alias'); ...
alias не вставил
Василий Наумкин
Понял, спасибо!
Создаём новую миграцию composer db:create и редактируем:
У меня после создания миграции создается файл - core/db/migrations/20230521075426.php. Судя по коду, к которому, как ты пишешь, нужно затем привести этот файл - у тебя в Github этот файл называется core/db/migrations/20220628050120_aliases.php.
Все последующие команды у меня потом выдают кучу ошибок, видимо намекающих, что название моего файла не правильное.
composer db:migrate
composer db:rollback
composer db:migrate
Например после composer db:rollback выдается:
Could not find class "V20230521075426" in file "/var/www/creonika/data/www/vespshop/core/db/migrations/20230521075426.php"
Видимо файл, который у меня создается после выполнения composer db:create также, как и у тебя должен называться 20220628050120_aliases.php . И поскольку ты ничего не пишешь, что этот файл нужно вручную переименовывать, значит он должен сразу создаваться с правильным названием. Но у меня почему-то это не так. Возможно ранее какие-то ошибки в файлах допустил. А так, все описанное ранее у меня получилось.
Василий Наумкин
Присоздании миграции нужно указывать для неё имя:
composer db:create Aliases
Так получилось) Спасибо!
Правильностью работы миграции я считаю возможность запустить migrate, а затем rollback и снова migrate без ошибок. Это будет означать, что миграция правильно откатывает все свои изменения.
Т.е. если у меня после выполнения composer db:migrate в админке и на фронте пропали все товары категории и картинки - значит у меня что-то было в файлах неправильно?
Василий Наумкин
Миграция просто выполняет какие-то изменения.
Какие именно ты выполнил изменения, что сразу всё пропало, я не знаю. Судя по твоим вопросам, может быть вообще что угодно.
да, собственно, выполнил 3 шага:
  1. выполнил composer db:create Aliases ;
  2. затем отредактировал, как ты пишешь, появившийся файл core/db/migrations/20230521103116_aliases.php .
  3. и потом выполнил composer db:migrate
Вот и все изменения. После этого все пропало. Т.е. все текущие шаги выполнил вроде как ты пишешь. Значит данные могли пропасть из-за каких-то ошибок, которые были ранее. Буду разбираться.
Василий Наумкин
Слушай, ну прямо в заметке же написано
Если бы наш магазин уже был в работе, то нам нужно было бы добавить новую колонку, затем прописать уникальные значения в каждую модель, и только потом добавлять индекс unique(). Но сейчас мы можем смело удалить все записи из БД, а после запуска миграции создать их снова через запуск seed.
И в коде самой миграции указано очистить таблицы с категориями и товарами.
        // Отключаем проверку связей с другими таблицами
        (new Eloquent())->getConnection()->getSchemaBuilder()->disableForeignKeyConstraints();
        // И удаляем все товары с категориями
        Category::query()->truncate();
        Product::query()->truncate();
Как ты так умудряешься уроки проходить, не читая текст?
Да, теперь появились! Спасибо за твое терпение!
Дмитрий
Здравствуйте.
В ModX есть полезная функция "заморозить url родителя". При ее включении вместо:
site.ru/основная-категория/дочерняя-категория-1/дочерняя-категория-2/дочерняя-категория-3/товар.html
УРЛ получается такой:

site.ru/товар.html
Короткие урлы "нравятся" поисковикам, поэтому эта функция полезна для продвижения сайта.
Насколько сложно реализовать такую функцию? 
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Дмитрий
21.12.2024, 13:27:06
Здравствуйте.В ModX есть полезная функция "заморозить url родителя". При ее включении вместо: УРЛ п...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!