Skip to content

Поставщики и сервис ​

Эта страница — для разработчика: как подключить свою модель, что умеет сервис и как устроена таблица.

Зарегистрировать свою модель ​

Две вещи: класс-поставщик и одна строка регистрации.

php
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 сайта:

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 без хозяина однажды встретится дважды. Второй поставщик на занятый ключ не регистрируется и пишет в лог: молча победивший последний означал бы, что состав избранного зависит от порядка загрузки компонентов.

Сервис ​

php
/** @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)повесить слушателя, см. «События» ниже

Срез и порядок — здесь, а не только в сниппете ​

php
$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 погасил бы кнопку одиннадцатой карточки.

Прогрев — единственное, о чём стоит помнить ​

php
$products = /* ... */;

$favorites->warm('pbshop.product', $products->pluck('id')->all());

// дальше кнопки в цикле уже не ходят в базу

has() без прогрева заполняет кеш по одной записи, то есть по запросу на кнопку. Сниппет сделать это за вас не может: он видит свой объект, а не список.

Что возвращает all() ​

php
[
    '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

Три способа подписаться, все равноправные.

Замыканием — там, где регистрируете поставщика:

php
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:

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() зовёт крон сайта, не пакет:

php
// bin/favorites-purge.php
modx()->services->get('pbfavorites')->purge(180);

Считается от даты создания, а не изменения: закладку не правят — её либо завели, либо убрали. Строки вошедших не трогаются ни при каком сроке: у них есть хозяин, который вернётся.

Нужно ли убирать закладки удалённого объекта. Для заявки, которая живёт пять дней, — да, иначе её закладки переживут её на годы:

php
$favorites->forget('mt.cargo', $cargo->id);

Для товара — нет: снятый с продажи возвращается, и закладка должна вернуться туда, куда её поставили. Поэтому вызов не внутри пакета: кто удаляет, тот и решает, навсегда ли.

Где лежит страница списка. Ресурс и его адрес — ваши, см. быстрый старт.

Таблица ​

pb_favorites:

КолонкаЧто
model_typeполное имя класса — то, что вернул modelClass()
model_idid объекта
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 при провале:

bash
php tests/registry.php          # белый список моделей
php tests/service.php           # порядок, срез, паритет со сниппетом, события
php tests/schema-compat.php     # совместимость схемы с живой таблицей
php tests/templates-compile.php # сборка шаблонов настоящим Fenom

schema-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 выключает компонент и пишет причину в журнал — вместо пустых блоков на витрине.

pbFavorites — закладки на любую модель, бесплатно