Поставщики и сервис
Эта страница — для разработчика: как подключить свою модель, что умеет сервис и как устроена таблица.
Зарегистрировать свою модель
Две вещи: класс-поставщик и одна строка регистрации.
use Boshnik\PbFavorites\Contracts\FavoriteProvider;
class ArticleFavorites implements FavoriteProvider
{
public function modelClass(): string
{
return Article::class;
}
/** @param array<int, int> $ids @return array<int, int> */
public function visible(array $ids): array
{
return Article::query()
->whereIn('id', $ids)
->where('published', 1)
->pluck('id')
->map(static fn($id) => (int) $id)
->all();
}
/** @param array<int, int> $ids @return array<int, array<string, mixed>> */
public function lines(array $ids, array $context = []): array
{
$lines = [];
foreach (Article::query()->whereIn('id', $ids)->get() as $article) {
$lines[(int) $article->id] = [
'title' => $article->title,
'url' => '/blog/' . $article->alias,
'image' => $article->image,
];
}
return $lines;
}
}Регистрация — из bootstrap.php вашего компонента или из core/App/config/*.php сайта:
use Boshnik\PbFavorites\Support\Registry;
Registry::register('blog.article', new ArticleFavorites());Всё. Дальше работают [[!pbFavoriteButton? &model=blog.article &id=42]] и остальные два сниппета.
Методы принимают массив, а не один id
И это не вкус. Двадцать закладок на странице при методе «по одному» становятся двадцатью запросами; именно из-за этого в исходной реализации появился прогрев. Пишите whereIn, а не цикл с запросом внутри.
Почему класс объявляется, а не берётся из модели
modelClass() возвращает имя явно, хотя у модели есть getMorphClass(). У AbstractDataModel тот отдаёт static::class, а у BaseModel — короткое имя класса. Зависеть от того, от какой базы наследуется чужая модель, значит однажды записать в одну таблицу два разных обозначения одного класса.
Ключ модели
Проверяется регуляркой ^[a-z][a-z0-9_.-]*$: строчные латинские, цифры, точка, дефис, подчёркивание.
Точка — разделитель области (pbshop.product, blog.article). На одном сайте живут магазин, блог и справочник, и product без хозяина однажды встретится дважды. Второй поставщик на занятый ключ не регистрируется и пишет в лог: молча победивший последний означал бы, что состав избранного зависит от порядка загрузки компонентов.
Сервис
/** @var \Boshnik\PbFavorites\PbFavorites $favorites */
$favorites = modx()->services->get('pbfavorites');Владельца методы спрашивают сами — вошедшего из сессии, гостя из cookie. Звать их можно откуда угодно, в том числе из шаблона.
| Метод | Что делает |
|---|---|
all(?string $model = null, array $context = [], array $options = []) | список: count, items, groups, ids |
count(?string $model = null) | сколько отложено и видно |
has(string $model, int $id) | отложено ли — для кнопки |
warm(string $model, array $ids) | прогреть страницу: один запрос на все кнопки |
watchers(string $model, int $id) | сколько человек отложило объект |
warmWatchers(string $model, array $ids) | то же на всю страницу сразу |
toggle(string $model, int $id) | переключить; гостю выдаёт токен, если его нет |
clear(?string $model = null) | очистить; без модели — весь список |
forget(string $model, int $id) | убрать закладки удалённого объекта |
purge(int $days = 180) | убрать брошенное гостевое |
register(string $model, FavoriteProvider $provider) | то же, что Registry::register() |
listen(string $event, $listener) | повесить слушателя, см. «События» ниже |
Срез и порядок — здесь, а не только в сниппете
$favorites->all('pbshop.product', [], [
'limit' => 20,
'offset' => 0,
'sortby' => 'price',
'sortdir' => 'desc',
]);Это те самые &limit, &offset, &sortby и &sortdir, которыми пользуется [[!pbFavorites]]: сниппет их только разбирает, считает сервис. Правило простое и проверяется тестом — всё, что умеет вызов в шаблоне, доступно прямым вызовом. Иначе разработчик, которому нужен срез, либо повторяет его у себя, либо зовёт сниппет из контроллера.
sortby сортирует по полю строки, а строку собирает поставщик: у товара есть price, у груза — нет. Поле, которого в строках нет, порядок не меняет и пишет в журнал — молча перемешанный список хуже отказа.
Срез применяется к items
count, groups и ids остаются полными: они описывают состав списка, а не число напечатанных строк. Иначе &limit=10 погасил бы кнопку одиннадцатой карточки.
Прогрев — единственное, о чём стоит помнить
$products = /* ... */;
$favorites->warm('pbshop.product', $products->pluck('id')->all());
// дальше кнопки в цикле уже не ходят в базуhas() без прогрева заполняет кеш по одной записи, то есть по запросу на кнопку. Сниппет сделать это за вас не может: он видит свой объект, а не список.
Что возвращает all()
[
'count' => 7,
'items' => [ /* плоский список, новые сверху */ ],
'groups' => [
'pbshop.product' => ['count' => 5, 'items' => [...]],
'blog.article' => ['count' => 2, 'items' => [...]],
],
'ids' => [
'pbshop.product' => [12, 34, ...],
'blog.article' => [7, 9],
],
]items — чтобы напечатать список, groups — чтобы разложить по разделам, ids — чтобы зажечь кнопки там, где карточек два десятка, а полные строки не нужны.
В каждой строке к полям поставщика добавлены model и item_id.
События
Компонент не надо патчить, чтобы навесить на закладки свою логику.
| Событие | Когда | Параметры |
|---|---|---|
pbFavoritesAdded | объект отложен | model, model_class, item_id, user_id, guest |
pbFavoritesRemoved | объект убран | те же |
pbFavoritesRefused | нажатие отклонено | те же + error — ключ лексикона |
pbFavoritesCleared | список очищен | model (пусто — весь), user_id, guest |
pbFavoritesMerged | гостевой список переехал при входе | user_id, moved, dropped |
Три способа подписаться, все равноправные.
Замыканием — там, где регистрируете поставщика:
use Boshnik\PbFavorites\Events\Dispatcher;
Dispatcher::listen(Dispatcher::ADDED, function (array $params, string $event) {
Rating::award($params['user_id'], 'favorite', $params['item_id']);
});Классом — в core/App/config/pbfavorites.php:
return [
'listeners' => [
'pbFavoritesAdded' => [\App\Listeners\AwardForFavorite::class],
'pbFavoritesRefused' => \App\Listeners\SuggestCleanup::class,
],
];У класса достаточно метода handle(array $params, string $event).
Плагином MODX — invokeEvent() зовётся после слушателей, поэтому обычный плагин тоже сработает. Плагину уходят только скаляры: массив и объект в $scriptProperties ему бесполезны и ломают кэширование событий.
Почему слушатели, а не системные события MODX
Как в pbShop и pbAuth: системное событие приходится руками прикреплять в менеджере после каждого деплоя, а на SFTP-сервере этот шаг молча забывается. Слушатели живут в git вместе с остальным кодом сайта.
Отказ — отдельное событие
pbFavoritesRefused приходит и на pbfav_err_limit. Упёршийся в предел — это тот, кому сайт может предложить разобрать список; узнать о нём иначе негде, а ответ сниппета видит только посетитель.
Исключение в слушателе пишется в журнал и не роняет нажатие: строка в базе уже появилась, и отказ после записи означал бы кнопку, которая «не работает» при отложенном объекте.
Что остаётся сайту
Компонент не решает за вас три вещи.
Когда чистить брошенное. purge() зовёт крон сайта, не пакет:
// bin/favorites-purge.php
modx()->services->get('pbfavorites')->purge(180);Считается от даты создания, а не изменения: закладку не правят — её либо завели, либо убрали. Строки вошедших не трогаются ни при каком сроке: у них есть хозяин, который вернётся.
Нужно ли убирать закладки удалённого объекта. Для заявки, которая живёт пять дней, — да, иначе её закладки переживут её на годы:
$favorites->forget('mt.cargo', $cargo->id);Для товара — нет: снятый с продажи возвращается, и закладка должна вернуться туда, куда её поставили. Поэтому вызов не внутри пакета: кто удаляет, тот и решает, навсегда ли.
Где лежит страница списка. Ресурс и его адрес — ваши, см. быстрый старт.
Таблица
pb_favorites:
| Колонка | Что |
|---|---|
model_type | полное имя класса — то, что вернул modelClass() |
model_id | id объекта |
user_id | вошедший владелец; NULL у гостевой строки |
token | гостевой токен; NULL у строки вошедшего |
data | необязательное, под свои поля |
created_at | целое число, время создания |
Обнуляемость обеих колонок владельца — не небрежность: MySQL пропускает в уникальный индекс сколько угодно NULL, но не два одинаковых значения. На этом держится «одна закладка на пару» и у вошедшего, и у гостя.
Индексы: uniq_user_object, uniq_token_object (оба уникальные), idx_user_type_time, idx_object, idx_created.
⚠️ Миграция садится на существующую таблицу
Если pb_favorites на сайте уже есть — своя, заведённая раньше пакета, — миграция её не создаёт и не перезаписывает, а дорастит: добавит token и data, снимет NOT NULL с user_id, доведёт индексы. Каждый шаг проверяется отдельно, поэтому повторный прогон безопасен.
Откат таблицу не удаляет: там, где её завёл сайт, она старше компонента и переживает его. Убираются только колонки, добавленные пакетом.
Это не предусмотрительность на всякий случай: ровно такой случай и был — на живом проекте таблица с этим именем появилась за два дня до того, как избранное выпустили в pbShop, с другой схемой и настоящими данными.
Переезд с pbShop
Вторая миграция переносит pb_shop_favorites в pb_favorites, если старая таблица есть; если нет — молчит, это норма для сайта без магазина. Старую таблицу не удаляет: пакет может встать раньше, чем обновится магазин, и снос до переноса стирал бы закладки покупателей.
Строки вошедших переезжают целиком и работают сразу. Гостевые переносятся тоже, но вместе с переездом сменилась cookie (pbshop_wish → pb_wish), поэтому прежний токен браузер больше не предъявит — такая строка доживёт до purge() и уйдёт как брошенная.
Маршруты переехали с /pay/favorites/* на /favorites/*.
Гостевая механика — не своя
Токен, его формат, срок, флаги cookie, выдача и гашение — это PageBlocks\Support\GuestToken (alfa24). Пакет своей копии не носит: до переезда этот приём был написан в pbShop трижды — корзина, сравнение, избранное, — и три копии успели разойтись в сроке жизни.
Пакету остаётся объявить своё: имя pb_wish и срок 180 дней (Support\Owner). Имя регистрируется в GuestCookies из bootstrap.php — иначе HTTP-кеш не отличит одного гостя от другого и отдаст второму страницу первого.
Если пишете свой компонент с гостевым состоянием
Берите GuestToken и регистрируйте имя cookie в GuestCookies — это ровно те две строки, которых не хватало, чтобы кеш не раздавал чужие корзины.
Кеш страниц
Изменение списка сбрасывает кеш ответов PageBlocks. Сервис делает это руками, и это важно знать, если будете править его: insertOrIgnore и массовое удаление идут мимо событий Eloquent, то есть мимо автоматического сброса в BaseModel. Без явного вызова гость, нажавший кнопку, получил бы в ответ свою же страницу из кеша — со старым состоянием кнопки.
Тесты
Скрипты, базы не требуют, exit 1 при провале:
php tests/registry.php # белый список моделей
php tests/service.php # порядок, срез, паритет со сниппетом, события
php tests/schema-compat.php # совместимость схемы с живой таблицей
php tests/templates-compile.php # сборка шаблонов настоящим Fenomschema-compat.php — главный. Он сверяет определения двух миграций и падает, если колонку переименуют: на боевом сайте это означало бы Unknown column вместо доращивания, а увидеть такое своими глазами негде — миграция-то пройдёт, таблица есть.
service.php сторожит паритет: он прогоняет arrange() и сверяет имена, которые отдаёт разбор параметров сниппета, с теми, которые понимает сервис. Разъедутся — &limit в шаблоне перестанет работать молча.
Шаблоны и платформа
Дефолтные чанки резолвит View::bundled() платформы, а не сам пакет: дефолт сознательно не участвует в общем резолвинге &tpl, иначе pageblocks_elements_path или pageblocks_file_elements_only сделали бы его недостижимым — и сниппет без единого параметра перестал бы выводить хоть что-то.
Имя файла совпадает с именем записи (pbFavorites.row → pbFavorites.row.tpl), источник pbFavorites регистрируется в bootstrap.php одной строкой ElementSources::register().
Нужна платформа с источником у bundled()
Раньше путь к поставочным файлам был прибит к папке PageBlocks, и спутник не мог позвать bundled() для своих дефолтов. У pbFavorites из-за этого был второй резолвинг дефолта, и он расходился с первым: при pageblocks_file_elements_only = 1 и существующей записи pbFavorites.row вывод пустел. Своей копии пакет больше не носит, поэтому на платформе без этого параметра bootstrap.php выключает компонент и пишет причину в журнал — вместо пустых блоков на витрине.