Laravel и теги с автодополнением

Сделать для того или иного материала теги — задача довольно типичная и распространенная. В этой заметке мы рассмотрим, как сделать модель тегируемой, а заодно — как реализовать автодополнение для этих тегов.

Для тегов в Laravel есть несколько пакетов (не с нуля же их писать), по описаниям мне подошел больше всего Spatie Laravel Tags. В нём работа с тегами реализована достаточно прозрачно и просто, плюс из коробки есть локализация для наименований тегов и slug-и для генерации страниц тегов.

Установить пакет просто:

composer require spatie/laravel-tags
php artisan vendor:publish --provider="Spatie\Tags\TagsServiceProvider" --tag="migrations"
php artisan migrate

Чтобы можно было тегировать свою модель, достаточно использовать трейт HasTags:

class Order extends Model
{
    use HasTags;
    // ...
}

Теперь если мы где-нибудь (скажем, в контроллере) напишем:

$order->syncTags(['Тег 1', 'Тег 2', 'Тег 3']);

— теги добавятся к модели. При этом библиотека сама создаст эти теги, проследит, чтобы не создавались дубли, сама приаттачит, сама удалит то, что нужно и т.д. Другие примеры использования есть в описании пакета.

Пока всё примитивно — достаточно передать массив тегов. Но этот массив еще нужно сгенерировать на стороне фронт-энда, для чего желательно реализовать автодополнение по уже существующим тегам, чтоб проще было вводить и выбирать.

Для этого нужно обустроить поиск по началу названия среди существующих тегов. В Laravel есть такой довольно удобный в применении пакет — Laravel Scout, неплохо годится как раз для индексируемого поиска. Поставить его тоже просто:

composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

А чтобы применять (то есть индексировать модели и искать по ним) — достаточно использовать трейт Searchable. Но модель Tag, используемая для создания тегов — находится в вендорном пакете spatie-laravel-tags и изменять её не хочется. Не проблема — пакет позволяет использовать для тегов собственные модели. Документация, правда, описывает этот момент недостаточно подробно, но можно додумать самому.

Для начала нужно унаследовать модель Tag. У меня теги — это направления (города и страны), поэтому я сделал модель DirectionTag. В ней уже можно использовать трейт Searchable:

class DirectionTag extends Tag
{
    use Searchable;

    protected $table = 'tags';
}

Обратите внимание, что для модели используется та же таблица tags, что и для родительской модели.

Также нужно указать, что наша модель Order должна использовать именно теги данного типа, переопределив соответствующий метод трейта HasTags:

class Order extends Model
{
    use HasTags;

    public static function getTagClassName(): string
    {
        return DirectionTag::class;
    }
}

В документации говорится, что этого достаточно. На деле, то ли что-то к версии 5.6 Ларавела поменялось, то ли просто не дописали, но надо еще переопределить метод tags() того же трейта:

public function tags(): MorphToMany
{
    return $this
        ->morphToMany(self::getTagClassName(), 'taggable', 'taggables', null, 'tag_id')
        ->orderBy('order_column');
}

Если не указать ключ tag_id, отношение Morph to Many будет пытаться найти поле direction_tag_id, опираясь на имя модели тега.

Теперь мы можем искать по тегам с помощью DirectionTag::search() и возвращать данные фронт-энду.

Нужно учитывать, что чтобы сделать теги локализуемыми — разработчики хранят их имена в столбце name типа JSON. То есть в столбце хранится строка вида { "en": "Russia", "ru": "Россия" }, а нужная версия автоматически определяется по установленной локали. Поэтому возвращать во фронт-энд результаты поиска лучше, отобрав заранее имена в текущей локали:

$directions = DirectionTag::search($query)->get();
return response()->json($directions->pluck('name'), 200);

Кроме того, если вы (как я в данный момент) используете драйвер MySQL для Scout — то имейте в виду, что MySQL сейчас не поддерживает FULLTEXT-поиск по столбцам типа JSON. Поэтому имеет смысл добавить в таблицу tags отдельный строковый столбец, содержащий индексируемое имя тега. Если локализация не нужна, то можно в код модели DirectionTag просто добавить обработчик события:

protected static function boot() {
    parent::boot();

    static::saving(function (DirectionTag $tag) {
        $tag->search_text = $tag->name;
    });
}

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

Наконец, чтобы не строить полнотекстового индекса по всяким лишним полям (например, по типу тега) — можно переопределить метод toSearchableArray() в модели тега и возвращать только значение, которое действительно нужно индексировать:

public function toSearchableArray() {
    return [
        'search_text' => $this->search_text,
    ];
}

И это всё, останется только реализовать ввод/загрузку тегов на стороне фронт-энда. Для чего можно использовать, например, годный компонент Vue Select2.

Комментарии