ewk / moonshine-resource-kit
Drag & drop sorting and activity toggling toolkit for MoonShine 4 resources.
Requires
- php: >=8.3
- laravel/framework: ^12|^13
- moonshine/moonshine: ^4.15
Requires (Dev)
- driftingly/rector-laravel: ^2
- friendsofphp/php-cs-fixer: ^3.65
- larastan/larastan: ^3
- mockery/mockery: ^1.2
- orchestra/testbench: ^10|^11
- pestphp/pest: ^3
- pestphp/pest-plugin-arch: ^3
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11|^12
- rector/rector: ^2
- tomasvotruba/type-coverage: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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)