Связи товара и мультикатегории
Продолжаем улучшать админку.
Сегодня вы переделаем карточку товара таким образом, чтобы добавить в неё вкладки с галереей, связями и категориями - это будет удобнее отдельных кнопок в таблице.
Галерея переедет как есть, а связи и категории товара напишем вместе с новыми контроллерами.
Вкладки в карточке
Для разбивки карточки товара по вкладкам используем компонент 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
👍
👎
❤️
🔥
😮
😢
😀
😡
210
23.08.2023 10:06:24
Комментарии
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
04.02.2025 19:27:08
Я таким давно не занимаюсь и с MODX не работаю.
Попробуйте обратиться к ребятам с modx.pro.
Создание нового проекта
65
Василий Наумкин
23.12.2024 05:33:00
В MODX сначала создали проблему, автоматически генерируя адреса, а потом "решили" заморозкой.
Так ч...
Вывод товаров на сайте
21
Дмитрий
14.12.2024 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Начинаем новый курс!
14
Василий Наумкин
05.12.2024 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Запуск в продакшн
55
Василий Наумкин
22.11.2024 03:33:54
Спасибо!
День рождения 42
5
inna
06.11.2024 15:47:13
Да. Все работает. Спасибо.
Vesp 3.0
108
Василий Наумкин
01.07.2024 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Оплата заказа
2
Василий Наумкин
26.06.2024 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Обновление проекта
2
Василий Наумкин
20.03.2024 21:21:52
Volledig!
Поездка в Швейцарию
8
Андрей
14.03.2024 13:47:10
Василий! Как всегда очень круто! Моё почтение!
День рождения 41
6
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!