Search by

ewk / moonshine-resource-kit

EWK

Drag & drop sorting and activity toggling toolkit for MoonShine 4 resources.

Package info

github.com/einzelwerk/moonshine-resource-kit

pkg:composer/ewk/moonshine-resource-kit

Statistics

Installs: 94

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-10-24 12:53 UTC

This package is auto-updated.

Last update: 2026-10-24 12:58:48 UTC


README

Тулкит для ресурсов MoonShine 4: drag & drop сортировка и переключатель активности.

Извлечён из паттернов, повторяющихся в реальных проектах: колонка sort_order с перетаскиванием строк в админской таблице и флаг is_active с переключением по одной записи и массово. Пакет — тонкий клей над встроенными механизмами MoonShine (reorderable()-таблицы на SortableJS, updateOnPreview() у переключателей): без собственного JS, всё настраивается на уровне модели/ресурса.

Требования

  • PHP 8.3+
  • Laravel 12/13
  • MoonShine 4.15+

Установка

composer require ewk/moonshine-resource-kit

Сервис-провайдер подключается автоматически. При необходимости опубликуйте конфиг и переводы:

php artisan vendor:publish --tag=moonshine-resource-kit-config
php artisan vendor:publish --tag=moonshine-resource-kit-lang

Сортировка

Модель

Добавьте колонку sort_order ($table->unsignedInteger('sort_order')->nullable();) и трейт:

use Ewk\MoonShineResourceKit\Concerns\Sortable;

class Article extends Model
{
    use Sortable;
}

Новые записи получают позицию max + 1 автоматически. Также трейт даёт:

Article::query()->orderedBySort()->get();   // sort_order, затем ключ; ->orderedBySortDesc() — оба по убыванию

$article->moveUp();
$article->moveDown();
$article->moveToStart();
$article->moveToEnd();
$article->moveToPosition(7);

Порядок записи — её ранг в группе: сначала позиция, при равных позициях — первичный ключ. Поэтому порядок один и тот же в любом запросе и на любой странице, даже если в колонке дубли или нули (после импорта, ручной правки, одновременных созданий).

Каждое перемещение и каждый drag & drop заново нумеруют группу плотно (1..N), так что дубли и пропуски исчезают при первом же изменении порядка. Позиция в moveToPosition() — это ранг (1 — первая запись), а не сырое значение колонки; moveUp() / moveDown() сдвигают на один ранг.

Стоимость: одно чтение ключей и позиций группы (SELECT ... FOR UPDATE, см. «Конкурентность») и один UPDATE ... CASE только по тем строкам, чей номер изменился (ещё по одному на каждые 500 изменённых строк). Для плотной группы обычно это 2 запроса. События модели не срабатывают, updated_at не меняется: сортировка не считается изменением записи (на updated_at опираются sitemap, кэши, CMS).

Своя колонка — переопределите в модели (глобальный дефолт задаётся в конфиге):

public function getSortColumn(): string
{
    return 'position';
}

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

public function getSortGroupColumns(): array
{
    return ['page_id'];
}

Без групповых колонок группа — вся таблица. Проиндексируйте групповые колонки (лучше составным индексом (page_id, sort_order)): от этого зависят и скорость, и ширина блокировки.

Ресурс

use Ewk\MoonShineResourceKit\MoonShine\Concerns\WithReorderable;
use MoonShine\Support\Enums\SortDirection;

class ArticleResource extends ModelResource
{
    use WithReorderable;

    protected string $sortColumn = 'sort_order';

    protected SortDirection $sortDirection = SortDirection::ASC;
}

Список должен быть отсортирован по колонке сортировки по возрастанию: перетаскивание читает строки сверху вниз как возрастающие позиции. Ключ как второй ключ сортировки WithReorderable добавляет в запрос индекса сам (в том же направлении, если список не отсортирован по ключу), так что строки с равной позицией не перескакивают между запросами и страницами.

Дальше включите drag & drop на индексной странице — либо трейтом страницы:

use Ewk\MoonShineResourceKit\MoonShine\Concerns\ReorderableIndex;

class ArticleIndexPage extends IndexPage
{
    use ReorderableIndex;
}

либо вручную в modifyListComponent():

protected function modifyListComponent(ComponentContract $component): ComponentContract
{
    return $this->getResource()->applyReorderable($component, $this);
}

Пагинация и большие таблицы

Drag & drop достаёт только до строк текущей страницы или отфильтрованного списка — и этого достаточно. Пакет берёт группу записи в текущем порядке (позиция, ключ), находит места («слоты»), которые занимают перетащенные строки, и раскладывает их по этим же слотам в новом порядке. Остальные строки — на других страницах или скрытые фильтром — остаются на своих слотах и в прежнем порядке относительно друг друга. Затем группа нумеруется заново 1..N, и записываются только строки, чей номер изменился.

Пример: в списке a b c d e f фильтр показывает b d f, их перетащили в порядок f b d — получится a f c b e d.

Ключи, которых нет в группе первой перетащенной записи (чужая группа, удалённая запись), игнорируются. Глобальные скоупы модели (например, SoftDeletes) при нумерации не применяются: такие записи тоже занимают слоты.

Чтобы переносить записи между страницами, добавьте кнопки перемещения на индексную страницу:

use Ewk\MoonShineResourceKit\MoonShine\Buttons\ReorderButtons;

protected function buttons(): ListOf
{
    return parent::buttons()->prepend(...ReorderButtons::for($this->getResource()));
}

Вы получите «вверх / вниз», «в начало / в конец» и модалку «на позицию». Перемещение — одно чтение группы и обновление только сдвинувшихся строк.

Конкурентность и блокировки

Перетаскивание и перемещение выполняются в одной короткой транзакции: группа читается SELECT ... FOR UPDATE — блокируются только строки этой группы, в порядке ключа, — затем идёт UPDATE, и транзакция закрывается. Два одновременных изменения одной группы выстраиваются в очередь, а не смешивают номера; при взаимоблокировке транзакция повторяется до трёх раз.

Почему не блокировка таблицы: сортируемые модели часто участвуют в денежных сценариях (например, оформление заказа держит блокировки строк тарифов). Блокировка всей таблицы остановила бы оформление на время сортировки; блокировка строк одной группы задевает только тех, кто работает с той же группой, и лишь на время короткой транзакции. Цена — нужно проиндексировать групповые колонки: без индекса InnoDB блокирует все просмотренные строки. Модель без групповых колонок блокирует все строки таблицы (по строкам, не таблицу целиком).

Блокировки отключаются в конфиге (lock_rows => false): тогда при одновременных изменениях одной группы побеждает последний.

Создание не блокируется. nextSortPosition() — это MAX + 1 без блокировки: два одновременных создания могут получить одинаковый номер. Тогда обе записи стоят в конце в порядке создания (ключ разрешает ничью), а ближайшее перетаскивание или перемещение перенумерует группу.

Ручка перетаскивания

По умолчанию тянется вся строка. Чтобы перетаскивать только за ручку:

use Ewk\MoonShineResourceKit\MoonShine\Fields\SortHandle;

protected function fields(): iterable
{
    return [
        SortHandle::make(),
        ID::make(),
        // ...
    ];
}

protected function reorderableWithHandle(): bool  // на индексной странице с ReorderableIndex
{
    return true;
}

Вложенные таблицы

Для таблиц HasMany, которые рендерятся внутри формы другого ресурса:

HasMany::make('Blocks', resource: BlockResource::class)
    ->modifyTable(app(BlockResource::class)->reorderableTableModifier()),

Переключатель активности

Модель

Добавьте boolean-колонку ($table->boolean('is_active')->default(true);) и трейт:

use Ewk\MoonShineResourceKit\Concerns\Activatable;

class Article extends Model
{
    use Activatable;
}

Вы получите activate() / deactivate() / toggleActive() / isActive(), скоупы active() / inactive() и boolean-каст. Чтобы использовать другую колонку, переопределите getActiveColumn().

Ресурс

use Ewk\MoonShineResourceKit\MoonShine\Concerns\WithActivatable;

class ArticleResource extends ModelResource
{
    use WithActivatable;
}

Переключение прямо из индексной таблицы:

use Ewk\MoonShineResourceKit\MoonShine\Fields\ActiveSwitcher;

protected function fields(): iterable
{
    return [
        // ...
        ActiveSwitcher::make('Активность'),
    ];
}

Кнопки массовой активации/деактивации:

use Ewk\MoonShineResourceKit\MoonShine\Buttons\ActivityButtons;

protected function buttons(): ListOf
{
    return parent::buttons()->prepend(...ActivityButtons::for($this->getResource()));
}

Глобальные скоупы (opt-in)

ActiveScope (скрывает неактивные записи) и SortOrderScope (неявная сортировка) поставляются в пакете, но никогда не регистрируются автоматически — глобальный скоуп, зависящий от текущего URL, стреляет в ногу в очередях, Octane и artisan. Регистрируйте их там, где ограничение реально нужно:

use Ewk\MoonShineResourceKit\Scopes\ActiveScope;

protected static function booted(): void
{
    static::addGlobalScope(new ActiveScope());
}

// обходной путь:
Article::query()->withInactive()->get();

Состояния фабрик

use Ewk\MoonShineResourceKit\Concerns\HasActiveFactoryStates;

class ArticleFactory extends Factory
{
    use HasActiveFactoryStates;
}

ArticleFactory::new()->active()->create();
ArticleFactory::new()->inactive()->create();
ArticleFactory::new()->randomActive()->create();

Конфигурация

config/moonshine-resource-kit.php:

Ключ По умолчанию Назначение
sort_column sort_order Глобальный дефолт для трейта Sortable
active_column is_active Глобальный дефолт для трейта Activatable
handle_class msrk-handle CSS-класс ячейки-ручки перетаскивания
lock_rows true Блокировать строки группы (FOR UPDATE) при перетаскивании и перемещении

Переопределения на уровне модели (getSortColumn(), getSortGroupColumns(), getActiveColumn()) всегда приоритетнее конфига.

Разработка

composer test          # arch + unit + feature
composer phpstan       # level max, только src/
composer format        # PHP-CS-Fixer (PER 3.0)

Лицензия

MIT