Фасетные фильтры

Сегодня вы узнаете как сделать свой собственный фасетный фильтр и не поехать кукухой.
Когда-то я провёл очень много времени изобретая подобные фильтры для 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.
Мне очень нравится работать с этим пакетом, использую его везде, где нужны фильтры. Если вам понравилась моя заметка, не поленитесь пройти в репозиторий Кирилла Егорова и поставить ему звёздочку, их там сейчас очень мало.
А все изменения одним коммитом как обычно, в моём репозитории.

Комментарии

bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Александр Наумов
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
Спасибо!