Фасетные фильтры
Сегодня вы узнаете как сделать свой собственный фасетный фильтр и не поехать кукухой.
Когда-то я провёл очень много времени изобретая подобные фильтры для MODX под названием mFilter. Честно говоря, я и не знал, что это именно фасетные фильтры.
Решение моё стало популярным, и теперь кажется, что ни один магазин без подобных фильтров существовать не может.
mFilter очень сильно завязан на MODX, поэтому для Vesp я использую замечательный пакет от российского автора k-samuel/faceted-search.
Сервис фильтрации
Делаем composer require k-samuel/faceted-search и создаём новый сервис Services/CategoryFilter, чтобы вызывать его в наших контроллерах.
Этот класс будет готовить фильтры для товаров одной или всех категорий, в зависимости от того, была ли ему передана модель категории при инициализации.
Привожу код в сокращённом виде, чтобы объяснить логику работы:
<?php
namespace App\Services;
// ...
class CategoryFilter
{
protected ?Category $category;
// Список доступных фильтров по типу
protected array $rangeFilters = ['price', 'weight'];
protected array $valueFilters = ['category', 'made_in'];
protected array $booleanFilters = ['new', 'popular', 'favorite'];
// Время кэширования собранного индекса
protected const CACHE_TIME = 600; // 10 минут
// При инициализации может быть указана категория для работы
public function __construct(?Category $category = null)
{
$this->category = $category;
}
// Этот метод строит индекс для фильтрации
protected function getIndex(): IndexInterface
{
$factory = (new Factory())->create(Factory::ARRAY_STORAGE);
$storage = $factory->getStorage();
// Пробуем получить данные из кэша
if ($cache = $this->getCache()) {
$storage->setData($cache);
} else {
// Кэша нет нет - собираем свежие данные
$products = Product::query()->where('active', true);
if ($this->category) {
// Если указана категория, выбираем её товары
$products->where(function (Builder $c) {
$c->where('category_id', $this->category->id);
if ($children = Category::getChildIds($this->category->id, true)) {
$c->orWhereIn('category_id', $children);
}
$c->orWhereHas('productCategories', function (Builder $c) {
$c->where('category_id', $this->category->id);
});
});
} else {
// Категория не указана, выбираем товары из активных категорий
$products->whereHas('category', static function (Builder $c) {
$c->where('active', true);
});
}
// Дальше проходимся по товарам и добавляем данные в индекс
foreach ($products->cursor() as $product) {
$record = [
'new' => $product->new,
'popular' => $product->popular,
'favorite' => $product->favorite,
];
if ($product->price) {
$record['price'] = $product->price;
}
// Категорию добавляем только если нужно
if (!$this->category || $this->category->id !== $product->category_id) {
$record['category'] = $product->category_id;
}
if ($product->weight) {
$record['weight'] = $product->weight;
}
if ($product->made_in) {
$record['made_in'] = $product->made_in;
}
// Добавление данных для товара
$storage->addRecord($product->id, $record);
}
// Сохранение в кэш
$storage->optimize();
$this->setCache($storage->export());
}
return $factory;
}
// Дальше 3 метода для работы с кэшированием
// Я просто сохраняю в JSON файлы с id категории
protected function getCacheName(): string
{
// ...
}
protected function getCache(): ?array
{
// ...
}
protected function setCache(array $data): void
{
// ...
}
// Этот метод выдаёт список фильтров для фронтенда
public function getFilters(array $selected = []): array
{
// Если прислали параметры от юзера - их нужно подготовить
$filters = $this->makeFiltersFromSelected($selected);
// Получаем массив с фильтрами и посчитанными и отсортированными результатами
$index = $this->getIndex();
$query = (new AggregationQuery())->filters($filters)->countItems()->sort();
$data = $index->aggregate($query);
// Выдаём эти данные на фронт в определённом едином формате
return $this->makeFiltersList($data);
}
// Этот метод выдаёт уже отфильтрованные id товаров
public function getProducts(array $selected = []): array
{
// Здесь так же могут быть указаны параметры фильтрации с фронта
$filters = $this->makeFiltersFromSelected($selected);
$index = $this->getIndex();
$query = (new SearchQuery())->filters($filters);
// Возвращаем массив с id
return $index->query($query);
}
// Этот метод задаёт условия фильтрации в зависимости от присланных параметров
// То есть здесь данные, прилетающие с фронта, превращаются в условия для фильтрации проиндексированных данных
protected function makeFiltersFromSelected(array $selected = []): array
{
$filters = [];
foreach ($selected as $key => $values) {
// Параметры для фильтра диапазона, такие как цена и вес, должны
// содержать массив ровно с 2мя значениями: от и до
if (in_array($key, $this->rangeFilters, true)) {
if (count($values) === 2) {
$filters[] = new RangeFilter($key, ['min' => $values[0], 'max' => $values[1]]);
}
} elseif (in_array($key, $this->valueFilters, true) || in_array($key, $this->booleanFilters, true)) {
// Все остальные фильтры получают данные как есть
$filters[] = new ValueFilter($key, $values);
}
}
return $filters;
}
// Этот метод готовит вывод массива фильтров для отрисовки на фронте
protected function makeFiltersList(array $input): array
{
$output = [];
// Мы проходим по полученным данным и конвертируем как надо
foreach ($input as $key => $values) {
$isBoolean = in_array($key, $this->booleanFilters, true);
$isRange = in_array($key, $this->rangeFilters, true);
if ($key === 'category') {
// Здесь выборка нужных данных для категорий, включая переводы
// ...
$output[] = [
'filter' => $key,
'type' => 'options',
'values' => $tmp,
];
} elseif ($isRange) {
// Данные для фильтров диапазона всегда состоят из 2х значений
$tmp = array_keys($values);
$output[] = [
'filter' => $key,
'type' => 'range',
'values' => [
'min' => (float)min($tmp),
'max' => (float)max($tmp),
],
];
} else {
// Остальные фильтры помещаются в массив как есть,
// кроме фильтров да\нет - они приводятся к bool
$tmp = [];
foreach ($values as $value => $items) {
$tmp[] = [
'value' => $isBoolean ? (bool)$value : $value,
'amount' => $items,
];
}
$output[] = [
'filter' => $key,
'type' => $isBoolean ? 'boolean' : 'options',
'values' => $tmp,
];
}
}
return $output;
}
}
Как видно из этого класса, у нас есть всего 2 публичных метода для вызова снаружи:
- getProducts() для получения id отфильтрованных товара
- getFilters() для получения массива фильтров
Массив получаемых фильтров выглядит примерно так:
[
{
"filter": "category",
"type": "options",
"values": [
{
"value": 11,
"amount": 1,
"extra": {
"translations": [
{
"category_id": 11,
"lang": "en",
"title": "Tabac pour pipes"
},
{
"category_id": 11,
"lang": "ru",
"title": "Pfeifentabak"
}
]
}
}
]
},
{
"filter": "favorite",
"type": "boolean",
"values": [
{
"value": false,
"amount": 16
}
]
},
{
"filter": "price",
"type": "range",
"values": {
"min": 6.6,
"max": 31.2
}
},
// ...
]
У каждой позиции есть следующие параметры:
- filter - уникальное имя собственно фильтра
- type - тип: option, range или boolean.
- values - массив со значениями фильтра
- value - название опции
- amount - количество подходящих товаров
- extra - необязательный ключ с любыми данными, полезными для отрисовки фильтра, в частности для переводов категорий
У фильтров range опций для выбора и amount нет, поэтому у них так:
- values
- min - минимальное значение диапазона
- max - максимальное значение
Этих данных нам более чем достаточно для отрисовки фильтров на фронтенде.
Контроллер фильтров
Для получения фильтров нам нужен новый контроллер. По логике работы с товарами, контроллеров должно быть 2.
Общий контроллер для фильтров всех товаров, вне зависимости от категории - Controllers/Web/Filters:
<?php
namespace App\Controllers\Web;
use App\Controllers\Traits\WebCategoryPropertyController;
use App\Services\CategoryFilter;
use Psr\Http\Message\ResponseInterface;
use Vesp\Controllers\Controller;
class Filters extends Controller
{
// Метод GET выводит JSON с готовыми фильтрами
public function get(): ResponseInterface
{
// Поддержка дочернего контроллера категории
$service = new CategoryFilter($this->category ?? null);
// Обратите внимание, мы не указываем здесь возможные данные от юзера
$filters = $service->getFilters();
// Массив в обычном формате Vesp для вывода списков данных
return $this->success([
'total' => count($filters),
'rows' => $filters,
]);
}
// Метод для вывода "отфильтрованных" фильтров
public function post(): ResponseInterface
{
$service = new CategoryFilter($this->category ?? null);
// А вот здесь мы уже передаём данные от юзера
$filters = $service->getFilters($this->getProperties());
return $this->success([
'total' => count($filters),
'rows' => $filters,
]);
}
}
Логика простая - через GET получаем все фильтры для вывода, через POST уже изменённые фильтры, в зависимости от присланных параметров с фронта.
Дочерний контроллер для фильтров категории Web/Category/Filters кладём рядом с контроллером вывод товаров категории. Логику проверки и загрузки категории из последнего выносим в общий трейт WebCategoryPropertyController.
А теперь пишем сам новый контроллер:
<?php
namespace App\Controllers\Web\Category;
use App\Controllers\Traits\WebCategoryPropertyController;
class Filters extends \App\Controllers\Web\Filters
{
use WebCategoryPropertyController;
}
Как видно, он просто использует новый трейт, тот загружает категорию, она попадает в $this->category, а дальше уже срабатывает логика в расширяемом родительском контроллере.
Конечно, не забываем прописать и новые маршруты:
$group->any('/filters', App\Controllers\Web\Filters::class);
$group->any('/category/{category_id}/filters', App\Controllers\Web\Category\Filters::class);
На бэкенде всё готово, переходим на фронтенд.
Компонент фильтров
Нам понадобится новый компонент list-products-filters, который будет работать по той же логике, что и вывод товаров:
- принимать параметр category
- менять адрес для запросов в звисимости от её наличия
Также этот компонент будет поддерживать v-model для выдачи выбранных фильтров в виде массива ключ-значения.
Компонент большой, привожу в сокращённом виде
<template>
<b-overlay :show="loading" opacity="0.5" spinner-type="none">
<!-- Отрисовываем все полученные фильтры -->
<b-form-group v-for="item in filters" :key="item.filter" :label="$t('filters.' + item.filter)">
<!-- Для вывода диапазонов используем слайдер -->
<template v-if="item.type === 'range'">
<input-range-slider
v-model="selected[item.filter]"
:min="item.values.min"
:max="item.values.max"
:precision="item.filter === 'weight' ? 0 : 1"
/>
</template>
<template v-else>
<!-- Все остальные фильтры отрисовываем чекбоксами -->
<b-form-checkbox-group v-model="selected[item.filter]" stacked>
<b-form-checkbox v-for="(opt, idx) in item.values" :key="idx" :value="opt.value" :disabled="!opt.amount">
<!-- Значения булевых фильтров оформляем как Да\Нет -->
<template v-if="item.type === 'boolean'">
{{ $t('filters.boolean.' + String(opt.value)) }}
</template>
<!-- Категории выводим переводами -->
<template v-else-if="opt.extra && opt.extra.translations">
{{ $translate(opt.extra.translations) }}
</template>
<!-- Остальные как есть -->
<template v-else>
{{ opt.value }}
</template>
<!-- И, наконец, циферки с количеством результатов -->
<sup>{{ opt.amount }}</sup>
</b-form-checkbox>
</b-form-checkbox-group>
</template>
</b-form-group>
<!-- Кнопка сброса фильтрации появляется когда что-то выбрано -->
<b-button v-if="hasSelected" @click="onReset">
{{ $t('filters.actions.reset') }}
</b-button>
</b-overlay>
</template>
Очевидно, что в зависимости от типа и\или имени фильтра вы можете отрисовать его как угодно. Например выводить булевы фильтры радиокнопками, а не чекбоксами - я просто не стал усложнять.
Дальше давайте посмотрим на javascript часть компонента:
// Для работы диапазонов я написал свой компонент, см. ниже
import InputRangeSlider from '~/components/inputs/range-slider.vue'
export default {
name: 'ListProductsFilters',
components: {InputRangeSlider},
// Поддержка получения v-model и категории
props: {
value: {
type: Object,
default: null,
},
category: {
type: [String, Number],
default: null,
},
},
// ...
// Изначальная загрузка фильтров для отрисовки
async fetch() {
try {
const {data} = await this.$axios.get(this.url)
// Выставляем фильтры для работы через метод, чтобы там их почистить, отсортировать - всё, что угодно
this.setFilters(data.rows)
// Сохраняем диапазоны, чтобы понимать, когда они изменены
this.setRanges(data.rows)
} catch (e) {}
},
computed: {
// Адрес для запросов зависит от категории
url() {
return this.category ? 'web/category/' + this.category + '/filters' : 'web/filters'
},
// Метод определения, есть ли активные фильтры
hasSelected() {
// ...
},
},
watch: {
selected: {
handler(newValue) {
// Смотрим за изменением фильтров,
// подготавливаем данные и посылаем наружу, в v-model
// ...
this.$emit('input', newValue)
// Ну и грузим изменённые фильтры, с учётом выбора
this.onFilter()
},
deep: true,
},
},
methods: {
// Загрузка изменённых фильтров методом POST
async onFilter() {
this.loading = true
try {
const {data} = await this.$axios.post(this.url, this.selected)
this.setAmount(data.rows)
} catch (e) {
} finally {
this.loading = false
}
},
onReset() {
this.selected = {}
},
// Этот метод должен проставить новые amount всем фильтрам
setAmount(items) {
// Если фильтра в ответе нет - то у него нет результатов, значит надо поставить amount = 0
// ...
},
// Обработка загруженных фильтров, сортировка и т.д.
setFilters(items) {
// ...
},
// Сохранение изначальных диапазонов
setRanges(items) {
// ...
},
},
}
В итоге мы кликаем по фильтрам, наружу выдаётся, что мы нажали, а на сервер отправляется POST запрос для обновления amount фильтров.
Теперь нужно обновить компонент list-products для поддержки фильтров:
export default {
props: {
// ...
filters: {
type: Object,
default: null,
},
async fetch() {
this.loading = true
try {
const params = {limit: this.limit, page: this.page, sort: this.sort, dir: this.dir}
// Поддержка фильтров
if (Object.keys(this.filters).length) {
// Фильтры улетят как строка JSON
params.filters = JSON.stringify(this.filters)
}
const {data} = await this.$axios.get(this.url, {params})
// ...
},
watch: {
// ...
// Слежение за изменением фильтров и вызов загрузки
filters: {
handler: '$fetch',
deep: true,
},
}
Остаётся только связать эти 2 компонента одной переменной, условно вот так:
<template>
<div>
<list-products-filters v-model="filters" />
<list-products :filters="filters" />
</div>
</template>
<script>
export default {
data() {
return {
filters: {}
}
}
}
</script>
Первый компонент меняет filters, второй реагирует на это и обновляет товары, отправляя на сервер изменённые фильтры в переменной.
Конечно, контроллер товаров должен принимать новый JSON параметр filters:
protected function beforeCount(Builder $c): Builder
{
$c->where('active', true);
// Прислали фильтры - выбираем подходящие товары
if ($filters = $this->getProperty('filters')) {
$service = new CategoryFilter();
$ids = $service->getProducts(json_decode($filters, true));
$c->whereIn('id', $ids);
} else {
// Фильтров нет - обычные условия
$c->whereHas('category', static function (Builder $c) {
$c->where('active', true);
});
}
return $c;
}
Смотрим на результат:
Компонент input-range-slider
Я не нашёл подходящего мне компонента для управления диапазонами, так что создал свой собственный, подключив очень популярное решение на чистом javascript без зависимотей - noUiSlider.
Именно его вы и видите в моих примерах. Код приводить не буду, можете посмотреть в репозитории. Для интеграции пришлось переделать готовые стили в SCSS, использующий переменные Bootstrap, иначе слайдеры смотрелись инородно.
Компонент нужно еще как следует погонять, отловить баги, а потом он скорее всего войдёт в состав vesp-frontend.
Заключение
Этого функционала нет в исходном проекте по переделке сайта с miniShop2 на Vesp, потому что на том сайте не было и mFilter2.
Я написал всё с нуля, за 2 дня - включая почти полный день на разработку input-range-slider. Согласитесь, это не так уж и много, благодаря тому, что всей основной работой заведует k-samuel/faceted-search.
Мне очень нравится работать с этим пакетом, использую его везде, где нужны фильтры. Если вам понравилась моя заметка, не поленитесь пройти в репозиторий Кирилла Егорова и поставить ему звёздочку, их там сейчас очень мало.
А все изменения одним коммитом как обычно, в моём репозитории.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
520
01.09.2023, 09:09:59
Комментарии
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