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

Продолжаем улучшать админку.

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

Галерея переедет как есть, а связи и категории товара напишем вместе с новыми контроллерами.

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

Для разбивки карточки товара по вкладкам используем компонент 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 `app_product_categories`
WHERE `product_id` = '2'
  and exists (
    SELECT *
    FROM `app_categories`
    WHERE `app_product_categories`.`category_id` = `app_categories`.`id`
      and exists (
        SELECT *
        FROM `app_category_translations`
        WHERE `app_categories`.`id` = `app_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 категорий, то не все они будут исключены из комбо, потому что не все будут загружены таблицей.

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

Здесь всё ровно также с одним исключением - комбо-бокс должен работать с товарами, а не категориями.

Такое у нас использутся впервые, поэтому я не буду создавать новый компонент для этой функции.

Исходный код смотрите в репозитории, а здесь я покажу результат:

Заключение

Не знаю как вам, а мне новая карточка товара очень нравится!

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

Итоговый коммит в репозитории.

← Предыдущая заметка
Сортировка моделей и генерация uri
Следующая заметка →
Импорт и управление заказами
Комментарии (0)
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 для бэкенда. Их можно обновлять, но э...