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

На прошлом уроке мы доработали наш вывод товаров, добавив к нему выборку картинок.

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

← Предыдущая заметка
Вывод изображений товаров
Следующая заметка →
Оформление товаров и категорий
Комментарии (19)
bezumkinВасилий Наумкин
30.06.2022 06:58

Есть ли возможность формировать "friendly URL aliases", используя аналог translit MODx?

Да, конечно, можно поискать и подключить нужную библиотеку. Я обычно для такого использую Slugify. Вот, написал компонент для ввода alias за 10 минут.

Почему у товаров alias уникален только в пределах одной категории?

Просто хотелось показать небольшую разницу в подходах между категориями и товарами. На реальном проекте всё зависит от логики разработчика. Можно генерировать alias и автоматически.

Может ли товар отображаться в разных категориях как в minishop2, что в том случае будет с alias товара? .

У нас - нет. Но, опять же, это всегда можно дописать. Помнишь, как мы связали товары с файлами через отдельную таблицу с составным ключом? Точно так же можно связать и товары с разными категориями.

Но на практике такое крайне редко нужно, обычно у товара только одна категория - и один уникальный адрес. В miniShop2 это встроено по умолчанию из-за его универсальности, а нам это не нужно, мы и не делаем.

Просто разный подход к разработке.

bezumkinВасилий Наумкин
09.08.2022 10:06

хотя http://vesp-shop.test/api/web/categories/category-2/products - все в порядке:

Нет, не всё, судя по картинке. У тебя же там alias = category-113, а не product-113, его и нужно указывать.

вижу 404 от Nuxt

Конечно, Nuxt не обслуживает запросы в API, это работа для PHP сервера, который работает на vesp-shop.test.

Так что никаких ошибок у тебя нет.

bezumkinВасилий Наумкин
09.08.2022 11:27

На здоровье!

bezumkinВасилий Наумкин
27.10.2022 00:47

Пиши тогда, в чем у тебя была проблема.

bezumkinВасилий Наумкин
27.10.2022 10:25

Понял, спасибо!

bezumkinВасилий Наумкин
21.05.2023 09:10

Присоздании миграции нужно указывать для неё имя:

composer db:create Aliases
bezumkinВасилий Наумкин
21.05.2023 11:04

Миграция просто выполняет какие-то изменения.

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

bezumkinВасилий Наумкин
21.05.2023 14:22

Слушай, ну прямо в заметке же написано

Если бы наш магазин уже был в работе, то нам нужно было бы добавить новую колонку, затем прописать уникальные значения в каждую модель, и только потом добавлять индекс unique(). Но сейчас мы можем смело удалить все записи из БД, а после запуска миграции создать их снова через запуск seed.

И в коде самой миграции указано очистить таблицы с категориями и товарами.

        // Отключаем проверку связей с другими таблицами
        (new Eloquent())->getConnection()->getSchemaBuilder()->disableForeignKeyConstraints();
        // И удаляем все товары с категориями
        Category::query()->truncate();
        Product::query()->truncate();

Как ты так умудряешься уроки проходить, не читая текст?

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...