Связи товара и мультикатегории

Продолжаем улучшать админку.
Сегодня вы переделаем карточку товара таким образом, чтобы добавить в неё вкладки с галереей, связями и категориями - это будет удобнее отдельных кнопок в таблице.
Галерея переедет как есть, а связи и категории товара напишем вместе с новыми контроллерами.

Вкладки в карточке

Для разбивки карточки товара по вкладкам используем компонент b-tabs, он у нас уже подключен.
Возможно вы никогда не замечали, но у формы внутри vesp-modal есть 2 особенности:
  • она врубает индикатор ожидания при отправке.
  • она требует заполнения обязательных полей даже при отправке по кнопке, которая, в общем-то, снаружи формы.
Для сохранения этих особенностей нам нужно расширить слот #default, с использванием параметров loading и submit.
Первый - это состояние загрузки, по которому мы будем включать компонент b-overlay, а второй - собственно метод отправки, который ищет скрытый <input type="submit" /> и нажимает на него через javascript, чтобы срабатывала проверка параметров required полей формы.
Кнопки отправки формы нам нужны только на первой вкладке, определять её мы будем через переменную tab. Если она больше 0 (вкладки считаются от нуля), то подвал модалки можно скрыть.
Зная всё это, приступаем к редактированию страницы admin/pages/products/create.vue
<template>
  <vesp-modal v-model="record" :url="url" :title="$t('models.product.title_one')" :hide-footer="tab > 0" size="lg">
    <template #default="{loading, submit}">
      <b-tabs v-model="tab" content-class="pt-3">
        <!-- Основная вкладка с товаром -->
        <b-tab :title="$t('models.product.tab_main')">
          <b-overlay :opacity="0.5" :show="loading">
            <b-form ref="form" @submit.prevent="submit">
              <form-product v-model="record" />
              <input type="submit" class="d-none" />
            </b-form>
          </b-overlay>
        </b-tab>

        <!-- Дополнительные вкладки, если в форме есть первичный ключ -->
        <template v-if="record.id">
          <b-tab :title="$t('models.product.tab_files')" lazy>
            <product-files :product-id="record.id" />
          </b-tab>
          <b-tab :title="$t('models.product.tab_categories')" lazy></b-tab>
          <b-tab :title="$t('models.product.tab_links')" lazy></b-tab>
        </template>
      </b-tabs>
    </template>
  </vesp-modal>
</template>

<script>
import FormProduct from '@/components/forms/product'
import ProductFiles from '@/components/product/gallery.vue'

export default {
  name: 'ProductCreatePage',
  components: {ProductFiles, FormProduct},
  data() {
    return {
      url,
      tab: 0,
      // ...
    }
  },
  // ...
}
</script>
Мне нравится как легко работа с галереей переехала на другу страницу - вот она польза от рабивки приложения на компоненты!
Дополнительные вкладки выводятся только для уже существующего товара:
Параметр lazy у вкладки говорит отрисовывать её только при показе. То есть, встроенные в неё элементы не будут добавляться в DOM и делать запросы на сервер, пока вкладка не будет активирована. А когда вы переключитесь на другую, старая выгрузится со страницы.
Осталось подчистить хвосты:
  • убираем кнопку файлов из tableActions на странице admin/pages/products.vue
  • удаляем директорию admin/pages/products/files
Работа с файлами у нас теперь внутри карточки товара:
Изменения галереи сохраняются сразу, так что кнопка сохранения прячется вместе с подвалом. Еще я немного обновил стили галереи - переделал display на grid вместо flex.

Компонент ProductCategories

Приступаем к редактированию категорий товара. Основная назначается в свойствах товара, а здесь будут дополнительные.
Создаём минимальный контроллер Controllers/Admin/Product/Categories.php:
<?php

namespace App\Controllers\Admin\Product;

use App\Controllers\Traits\ProductPropertyController;
use App\Models\ProductCategory;
use Vesp\Controllers\ModelController;

class Categories extends ModelController
{
    use ProductPropertyController;

    protected $scope = 'products';
    protected $model = ProductCategory::class;
    protected $primaryKey = ['product_id', 'category_id'];

    protected function beforeCount(Builder $c): Builder
    {
        $c->where('product_id', $this->product->id);

        return $c;
    }
}
Контроллер использует те же проверки, что и соседний Product\Files, поэтому я вынес их в отдельный трейт ProductPropertyController.
Контроллер работы со связями тоже будет его использовать.
<?php

namespace App\Controllers\Traits;

use App\Models\Product;
use Psr\Http\Message\ResponseInterface;

trait ProductPropertyController
{
    protected ?Product $product;

    public function checkScope(string $method): ?ResponseInterface
    {
        if ($check = parent::checkScope($method)) {
            return $check;
        }

        if (!$this->product = Product::query()->find($this->getProperty('product_id'))) {
            return $this->failure('', 404);
        }

        return null;
    }
}
Теперь добавим обработку поиска. Контроллер работает с моделью ProductCategory и должен искать Category у которой есть нужный текст в CategoryTranslation.
    protected function beforeCount(Builder $c): Builder
    {
        $c->where('product_id', $this->product->id);

        if ($query = trim($this->getProperty('query', ''))) {
            $c->whereHas('category', static function (Builder $c) use ($query) {
                $c->whereHas('translations', static function (Builder $c) use ($query) {
                    $c->where('title', 'LIKE', "%$query%");
                    $c->orWhere('description', 'LIKE', "%$query%");
                });
            });
        }

        return $c;
    }
Ищем по title и description. Обратите внимание, что поиск будет произведён сразу по все языкам категории - приятный бонус от отдельного хранения переводов.
Запрос получится вот такой:
SELECT *
FROM `old_product_categories`
WHERE `product_id` = '2'
  and exists (
    SELECT *
    FROM `old_categories`
    WHERE `old_product_categories`.`category_id` = `old_categories`.`id`
      and exists (
        SELECT *
        FROM `old_category_translations`
        WHERE `old_categories`.`id` = `old_category_translations`.`category_id`
          and (
            `title` LIKE '%test%'
            or `description` LIKE '%test%'
          )
      )
  )
LIMIT 20 offset 0
Не забываем прописать новый маршрут в core/routes.php по образу и подобию работы с файлами товара:
$group->any('/product/{product_id}/categories[/{category_id}]', App\Controllers\Admin\Product\Categories::class);
Переходим на фронтенд. Мы будем выводить категории таблицей с комбо-боксом наверху, для добавления новых категорий.
Это уже третий раз как мы будем использовать подобный комбо-бокс, поэтому я выделил его в отдельный компонент admin/components/inputs/category.vue:
<template>
  <vesp-input-combo-box v-model="myValue" url="admin/categories" v-bind="$props" v-on="$listeners">
    <template #default="{item}">
      <div :class="{'text-muted': !item.active}">
        {{ $translate(item.translations) }}
        <div class="small text-muted">{{ item.uri }}</div>
      </div>
    </template>
  </vesp-input-combo-box>
</template>

<script>
import {BFormInput} from 'bootstrap-vue'

export default {
  name: 'InputCategory',
  props: {
    ...BFormInput.extendOptions.props,
    value: {
      type: [String, Number],
      required: true,
    },
    // ...
    formatValue: {
      type: Function,
      default(item) {
        return this.$translate(item.translations)
      },
    },
    // ...
  },
  // ...
}
</script>
Теперь вместо прошлой портянки в формах категории и товара можно просто импортировать новый компонент:
<template>
    <!-- -->
    <b-form-group :label="$t('models.product.category')">
      <input-category v-model="record.category_id" />
    </b-form-group>
    <!-- -->
</template>
<script>
import InputCategory from '@/components/inputs/category.vue'

export default {
  name: 'FormProduct',
  components: {InputCategory},
  // ...
}
</script>
Благодаря общему компоненту, выбор категории везде выглядит и работает одинаково.
Теперь создаём фронтенд-компонент admin/components/product/categories.vue:
<template>
  <vesp-table
    :url="url"
    :fields="fields"
    :table-actions="tableActions"
    :filters="filters"
    :on-load="onLoad"
    primary-key="category_id"
  >
    <template #header>
      <b-row class="align-items-center mb-3">
        <b-col md="6">
          <input-category v-model="category" :filter-props="{exclude}" />
        </b-col>
        <b-col md="4" offset-md="2" class="mt-2 mt-md-0">
          <!-- Поле для поиска -->
      </b-row>
    </template>
    <template ...>
      <!-- Оформление колонок title и file -->
    </template>
  </vesp-table>
</template>
Мы переопределяем шапку таблицы, чтобы использовать наш новый input-category с исключением некоторых категорий, обработку которого нужно добавить в контроллер категорий.
<script>
import InputCategory from '@/components/inputs/category.vue'

export default {
  name: 'ProductCategories',
  components: {InputCategory},
  props: {
    // Компонент требует указать id товара
    productId: {
      type: [Number, String],
      required: true,
    },
  },
  data() {
    return {
      // Заготавливаем нужные реактивные переменные
      exclude: '',
      category: null,
      filters: {
        query: '',
      },
    }
  },
  computed: {
    // Адрес для работы таблицы зависит от id товара
    url() {
      return `admin/product/${this.productId}/categories`
    },
    // Подключение категории вместе с файлом нужно добавить в
    // afterCount контроллера
    fields() {
      return [
        {key: 'category_id', label: this.$t('components.table.columns.id')},
        {key: 'file', label: this.$t('models.category.file')},
        {key: 'title', label: this.$t('models.category.title')},
      ]
    },
    // Из действий только удаление
    tableActions() {
      return [{function: 'onDelete', icon: 'times', title: this.$t('actions.delete'), variant: 'danger'}]
    },
  },
  // Следим за значением input-category
  watch: {
    category(newValue) {
      if (newValue) {
        this.onSelect(newValue)
      }
    },
  },
  methods: {
    // При загрузке таблицы сохраняем категории товара в переменную
    // для исключения из комбо-бокса
    onLoad(data) {
      this.exclude = data.rows.map((i) => i.category_id)
      return data
    },
    // Добавление новой категории товару
    async onSelect(id) {
      try {
        await this.$axios.put(this.url, {category_id: id})
      } catch (e) {}
      // Очистка значение комбо
      this.$nextTick(() => {
        this.category = null
      })
      // Генерация события на обновление таблицы
      this.$root.$emit(`app::${this.url.split('/').join('-')}::update`)
    },
  },
}
</script>
Итак, загружается таблица, вызывает метод onLoad, категории товара сохраняются в переменную exclude, чтобы исключить из выборки комбо-бокса.
При изменении комбо срабатывает метод onSelect и добавляет новую категорию, после чего таблица перезагружается и далее по кругу.
Удаление работает через встроенную функцию vesp-table, нужно только указать параметр primary-key, чтобы он подставлялся в запрос. Id товара у нас уже указан в url, значит остаётся добавить только category_id, чтобы оба параметра улетели на сервер.
Очень простая и красивая схема работы, с единственным недостатком - если у товара будет больше 20 категорий, то не все они будут исключены из комбо, потому что не все будут загружены таблицей.
Не думаю, что такое получится на практике, поэтому не буду вносить специальные условия в контроллер категорий.

Компонент ProductLinks

Здесь всё ровно также с одним исключением - комбо-бокс должен работать с товарами, а не категориями.
Такое у нас использутся впервые, поэтому я не буду создавать новый компонент для этой функции.
Исходный код смотрите в репозитории, а здесь я покажу результат:

Заключение

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

Комментарии

bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
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
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!