Связи товара и мультикатегории
Продолжаем улучшать админку.
Сегодня вы переделаем карточку товара таким образом, чтобы добавить в неё вкладки с галереей, связями и категориями - это будет удобнее отдельных кнопок в таблице.
Галерея переедет как есть, а связи и категории товара напишем вместе с новыми контроллерами.
Вкладки в карточке
Для разбивки карточки товара по вкладкам используем компонент 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.
Итоговый коммит в репозитории.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
180
23.08.2023, 10:06:24
Комментарии
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Vesp 3.0
101
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Оплата заказа
2
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Обновление проекта
2
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
Создание нового проекта
63
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Поездка в Швейцарию
8
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
День рождения 41
6
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Релиз @vesp/nuxt-fontawesome
3
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо!
Извини, тупанул.
Новая структура таблиц магазина
15
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Начинаем новый курс
4
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!
Запуск в продакшн с помощью Docker
20