Последние полгода наше комьюнити CutCode работает над новой версией нашей open-source админ-панели MoonShine. И вот недавно состоялся релиз MoonShine 2. Давайте пройдемся по всем значимым изменениям! Конечно, в одной статье я не смогу осветить все нововведения, но попробую сделать это по-максимуму. Ну а также расскажу о ближайших планах на MoonShine 3.

Новые требования

Laravel >= 10.20

PHP >= 8.1

Новый подход

Если смотреть на MoonShine 2 чисто визуально, то отличий не так и много. Да, у нас немного изменились некоторые поля и компоненты, появилось верхнее меню, но все это мелочь по сравнению с изменениями под капотом - там мы переписали 90% кода. В MoonShine 2 полностью новое ядро, которое дает невероятное количество дополнительных возможностей для разработчиков.

Ресурсы

Ресурсы уже не будут прежними. Теперь базовый ресурс вообще ничего не знает о Eloquent моделях и может работать с любым другим хранилищем.

Мы реализовали ресурс для моделей и называется он ModelResource. Те, кто использовал MoonShine 1, даже не увидят разницы в подходе. Но здесь нужно обратить внимание на то, что ресурс теперь изолирован и вы можете написать реализацию своего хранилища: будь то данные из внешнего api или парсинг лог файлов. Тут уже вы ограничиваетесь только своей фантазией.

Страницы

Новый постоянный житель MoonShine. Теперь страницы это основа MoonShine. Да у нас уже были кастомные страницы (которых к слову теперь нет), но текущие страницы не имеют границ и могут работать даже без ресурса. Ответственность у страниц это отображение компонентов, которых может быть сколько угодно - они могут быть компонентами MoonShine, либо просто blade компонентами или даже livewire! В итоге можно сказать, что ресурс это бокс с общей логикой для набора страниц. Но и опять-таки страницы могут существовать и без ресурса. Профиль пользователя это просто MoonShine-страница, которую можно заменить на свою, тоже самое касается и дашборда (главной страницы)

public function components(): array
{
    return [
        FormBuilder::make()->fields([
            Block::make([
                Grid::make([
                    Column::make([
                        Heading::make('Text'),

                        ID::make(),
                        Hidden::make('Hidden'),

                    ])->columnSpan(6),
                    Column::make([
                        Heading::make('Textarea'),

                        Textarea::make('Textarea'),
                        TinyMce::make('TinyMce'),
                    ])->columnSpan(6),
                ]),

                LineBreak::make(),
            ]),
        ])->submit('Submit', ['class' => 'btn-lg btn-primary']),
    ];
}

Слои

Как я уже говорил ранее, для вашего удобства мы уже написали ресурс для моделей - ведь все-таки мы используем Laravel, и я думаю в 99% случаев ресурсы будут на основе моделей. Также к ресурсу мы добавили готовые страницы:

  • для листинга записей (IndexPage),

  • добавления/редактирования(FormPage) ,

  • детальная(DetailPage).

Чтобы вам удобно было располагать и наполнять компоненты на этих страницах, мы добавили подход с использованием слоев (Layers). В итоге страницы выглядят следующим образом:

public function components(): array
{
    return array_merge(
        $this->topLayer(),
        $this->mainLayer(),
        $this->bottomLayer(),
    );
}

Теперь, для того чтобы добавить компоненты сверху, достаточно переопределить метод topLayer.

public function topLayer(): array
{
    return [
        Flex::make([
            Heading::make('Title')
        ])
            ->customAttributes(['class' => 'mb-4'])
            ->justifyAlign('end')
    ];
}

Поля

Всё что нужно знать сейчас о полях, это то, что они также изолированы от модели и могут работать и без нее. Более подробно мы обсудим поля чуть ниже, когда будем рассматривать новые фичи.

Компоненты

Компоненты теперь сердце MoonShine. Всё что вы видите в MoonShine сделано компонентами. Вы можете их добавлять, перемещать, добавлять свои. За счет компонентов мы получили полноценный конструктор, и теперь процесс работы с админ-панелью напоминает сборку кубиков конструктора LEGO - очень увлекательный процесс! Ну и само собой, появилось много новых компонентов и декораций из коробки, о которых вы узнаете в документации.

LayoutBuilder

Изменять структуру основного шаблона теперь проще некуда. Убирайте компоненты, добавляйте свои, используйте декорации - буквально рисуйте шаблон по-своему! Давайте рассмотрим пример, где мы убираем боковую панель и подключаем верхнее меню:

final class MoonShineLayout implements MoonShineLayoutContract
{
    public static function build(): LayoutBuilder
    {
        return LayoutBuilder::make([
            Sidebar::make([
                Menu::make()->customAttributes(['class' => 'mt-2']),
            ]),
            LayoutBlock::make([
                Flash::make(),
                Header::make(),
                Content::make(),
                Footer::make()->copyright(fn (): string => <<<'HTML'
                        © 2021-2023 Made with ❤️ by
                        <a href="https://cutcode.dev"
                            class="font-semibold text-primary hover:text-secondary"
                            target="_blank"
                        >
                            CutCode
                        a>
                    HTML)->menu([
                    'https://moonshine.cutcode.dev' => 'Documentation',
                ]),
            ])->customAttributes(['class' => 'layout-page']),
        ]);
    }
}

ActionButtons

Те, кто уже давно используют MoonShine, помнят, что у нас для кнопок в таблице были ItemAction, для массовых действий BulkAction, а в форме FormAction, а на детальной странице DetailAction, ах да еще общие Action для главной страницы сверху. Чуть сам не запутался пока писал) Но все это в прошлом! Встречайте - теперь кнопками правит ActionButton и эти красавцы умеют гораздо больше, чем толпа предыдущих сущностей вместе взятых.

ActionButton::make('Create', $resource->route('crud.create'));

Нужно вызвать модалку по клику, с любым содержимым или подтверждением действий? Не проблема, для этого есть метод - inModal или withConfirm.

ActionButton::make('Create', $resource->route('crud.create'))
                ->inModal(fn() => 'Create', FormBuilder::make()),

Хотите открыть offcanvas - пожалуйста, воспользуйтесь методом inOffCanvas.

ActionButton::make('Filtes', '#')
            ->secondary()
            ->icon('heroicons.outline.adjustments-horizontal')
            ->inOffCanvas(
                fn (): array|string|null => __('moonshine::ui.filters'),
                fn (): FormBuilder => new FiltersForm()
            )

Кнопка может переходить на любой урл, а также можно получить асинхронно контент или загрузить блейд фрагмент (об этом немного позже), а самое важное что рендерятся они где угодно в MoonShine и есть хелперы для вызова в блейд.

actionBtn('Create', route('example.url'))
                ->inModal(fn() => 'Create', async: true),

Думаю есть те кто будет ругать js в php классе, но такая возможность присутствует и стоит о ней сказать

ActionButton::make('Create', $resource->route('crud.create'))
                ->onClick(fn() => 'alert()', 'prevent'),

FormBuilder

Прежде чем мы поговорим о новых возможностях полей, стоит заметить, что изменена их концепция. В MoonShine 1 поля были привязаны к ресурсу и их тяжело было применять за его пределами. Теперь поля можно рендерить отдельно где угодно, но есть и места где им самое место - и это конечно же форма! С приходом MoonShine 2 мы рады представить FormBuilder, c помощью которого вы можете легко наполнить форму полями и декорациями, указать какой тип данных будет у полей (TypeCasts, подробнее в документации), а также использовать Precognition или асинхронное сохранение.

TableBuilder

Еще одно важное место для хранения полей - TableBuilder. Теперь создать таблицу в MoonShine или за её пределами - не проблема. Наполняем её любыми данными и указываем тип данных с помощью TypeCasts. Как и с формами, поддерживается асинхронный режим. Появился инструмент для управления атрибутами ячеек и строк, теперь добавлять им классы, стили, да всё что угодно - можно через ComponentAttributeBag.

Fields

Как бы там ни было, но поля - это основа MoonShine, без них точно никуда. И они всюду. Давайте разберем, что появилось нового! Для начала еще раз повторюсь, что поля ничего не знают о моделях (за исключением полей отношений, тут уж никуда без моделей) и могут наполняться любыми данными, даже за пределами MoonShine. Также появились новые поля, о которых можно почитать подробнее в документации.

Помните фильтры? Ну так вот - их больше не существует и теперь поля могут быть фильтрами, а логику фильтрации вы сможете менять “на лету”.

Теперь можно получать доступ к вложенным элементам через точку (‘user.email’):

Preview::make('Email', 'user.email');

Также логику можно выносить в отдельные Apply-классы, и регистрировать в системе. О Apply классах и MoonShineRegister подробнее смотрите в документации.

Поле Enum прокачали и добавили возможность указывать заголовок и цвет бейджа прямо в Enum, достаточно просто использовать методы getColor/toString.

enum ColorEnum: string
{
    case Red = "R";

    case Black = "B";
    
    case White = "W";

    public function getColor(): string
    {
        return match ($this->value) {
            "R" => 'purple',
            "B" => 'gray',
            "W" => 'green'
        };
    }

    public function toString(): string
    {
        return match ($this->value) {
            "R" => 'Purple',
            "B" => 'Gray',
            "W" => 'Green'
        };
    }
}

BelongsToMany теперь работает со всеми типами полей для pivot значений.

BelongsToMany::make('Categories', 'categories', resource: new CategoryResource())
    ->fields(function () {
        return [
            Date::make('Created at')->format('d.m.Y'),
        ];
    })

Async

Select можно указать url и получать опции селекта асинхронно, достаточно только соблюдать структуру ответа, которую мы укажем в документации.

Select::make('Select')
   ->async(route('async-search'))
   ->searchable(),

UpdateOnPreview

Отобразить поля теперь можно в таблице в виде элемента формы и асинхронно сохранять при изменении.

Text::make('Title')
    ->updateOnPreview(fn ($item) => $this->route('resource.update-column', $item->getKey()))

beforeRender/afterRender/changePreview/addAssets/onApply/onBeforeApple/onAfterApple/onAfterDelete

Мы улучшили интерфейс полей и добавили больше возможностей кастомизации без создания отдельного класса. Вот как это работает:

Text::make('Thumbnail')
   // Добавили дополнительные скрипты и стили для поля
    ->addAssets(['custom.js', 'custom.css'])
   // В форме до элемента отобразили превью с картинкой
    ->beforeRender(function (Text $field) {
        return $field->preview();
    })
    // Изменили превью, вместо текста отображаем изображение
    ->changePreview(function ($value) {
        return view('moonshine::ui.image', [
            'value' => $value ? Storage::url($value) : null,
        ]);
    })
   // Изменили логику сохранения, где мы не просто сохраняем текст а загружаем изображение по указанному урл
    ->onApply(function (Article $item, string $value) {
        $path = 'thumbnail.jpg';

        if ($value && $value !== $path) {
            Storage::put($path, file_get_contents($value));
            $item->thumbnail = $path;
        }

        return $item;
    }),

Badge/link

Добавить ссылку к полю или обернуть его в badge теперь можно для всех текстовых полей

Email::make('Title')
    ->badge('purple')
    ->link(fn($value) => "mailto:$value", 'Go to'),

Json

Поле прокачали по-полной, принимает любые типы полей и даже может работать как отношение, отображая форму или таблицу прямо в основной форме

Json::make('Comments')
    ->asRelation(new CommentResource())
    ->fields([
        ID::make(),
        BelongsTo::make('Article')
            ->setColumn('article_id')
            ->searchable(),
        BelongsTo::make('User')
            ->setColumn('user_id'),
        Text::make('Text')->required(),
        Image::make('Files')
            ->multiple()
            ->removable()
            ->disk('public')
            ->dir('comments'),
    ])
    ->creatable()
    ->removable()

Resource

Еще раз напомню, что в MoonShine 2 ресурс теперь может работать и не на основе моделей. Но все-таки ресурс с моделями у нас прямо в коробке. И он очень похож на тот, который мы использовали в первой версии. Но также появились и новые подходы, а именно:

  1. Раздельный подход, где у нас под каждый тип полей (для главной, формы, детальной страницы) свои отдельные методы.

  2. С публикацией всех страниц ресурса, которые вы сможете кастомизировать по-своему.

  3. Полностью пустой ресурс для нетривиальных задач.

Иконку можно менять прямо в ресурсе через атрибут

#[Icon('heroicons.users')]
class ArticleResource extends ModelResource

Был прокачан поиск по ресурсу и по json, доступ к отношениям и многое другое (смотрим документацию).

Basic

Fragments

Появилась новая декорация Fragment, которая дает возможность использовать blade fragments, отмечать блоки на странице и подгружать их асинхронно.

Fragment::make([
    TableBuilder::make(items: $this->getResource()->paginate())
        ->fields($this->getResource()->getIndexFields())
        ->buttons([
            ...$this->getResource()->getIndexButtons(),
        ]),
])->withName('crud-table'),

Generics

Мы стараемся улучшать интерфейс и внедрили аннотации с дженериками для нашего с вами удобства.

Произвольные правила авторизации

Будет полезно для тех, кто пишет пакеты для MoonShine. Теперь можно регистрировать собственные правила валидации и все они будут применяться в ресурсе.

public function boot(): void
{
    MoonShine::defineAuthorization(
        static function (ResourceContract $resource, Model $user, string $ability, Model $item): bool {
            $hasUserPermissions = in_array(
                HasMoonShinePermissions::class,
                class_uses_recursive($user),
                true
            );

            if (! $hasUserPermissions) {
                return true;
            }

            if (! $user->moonshineUserPermission) {
                return true;
            }

            return isset($user->moonshineUserPermission->permissions[$resource::class][$ability]);
        }
    );
}

Handlers

Удобные классы для реализации логики в MoonShine.

class ExportHandler extends Handler
{
    public function handle(): Response
    {
        // Logic
    }
}

Красота и сахар

Теперь прямо из коробки доступен компонент с верхним меню вместо левого сайдбара.

return LayoutBuilder::make([
    TopBar::make([
        Menu::make()->top(),
    ]),
    // ...
]);

Кастомизация темы и цветовой схемы. Фон, кнопки и другие элементы запросто можно перекрасить.

protected function theme(): array
{
    return [
        'colors' => [
            'body' => '#fff'
        ]
    ];
}

Директива с ассетами MoonShine, чтобы можно было использовать возможности MoonShine за её пределами.

<head>
    @moonShineAssets
head>

Sortable прямо в коробке и уже интегрированы в поля Json/Image/File

x-data="sortable"

Helpers для тех кому нравятся хелперы

moonshine()
moonshineRegister()
to_page()
moonshineRequest()
moonshineAssets()
moonshineMenu()
moonshineLayout()
form()
table()
actionBtn()
// ...

Toasts improvements

Toasts можно вызвать в js.

Stubs для всего

Да кстати, мы интегрировали Laravel Prompts во все консольные команды. Понять структуру кастомных полей или компонентов для разработчиков теперь будет куда проще. Да и установка MoonShine теперь сводится к одной команде.

php artisan moonshine:field
php artisan moonshine:component
php artisan moonshine:apply
php artisan moonshine:controller
php artisan moonshine:layout
php artisan moonshine:page

Новый домен

Проект разрастается и переехал на новый домен - https://moonshine-laravel.com

План на 3.0

  • Интеграция Laravel Echo, наконец-таки вебсокеты

  • Multi tenancy

  • Бесконечная вложенность в меню

  • Асинхронное удаление полей и записей

  • Новые шаблоны и темы

  • 2FA

  • Вложенные ресурсы

Заключение

Обновление получилось очень большим и трудозатратным. И я рассказал только о самых больших изменениях. Подробнее можно посмотреть в обзорном видео:

Работая по MoonShine v.2 мы реализовали много пожеланий участников нашего комьюнити, а некоторые разработчики втягиваются в разработку, реализовывая свои же предложения. Всех Laravel-разработчиков приглашаю присоединиться - использовать MoonShine2 в своих проектах и участвовать в open source разработке.

Данил Щуцкий, автор проекта CutCode.

Комментарии (6)


  1. ajaxtelamonid
    23.11.2023 07:19

    Bill Bob: "Howdy Joe! We've been moonshining, want some?"
    Joe: "OK! I love drinking that alcyhol"
    gulp gulp
    Joe: "OH SHIT, MY EYES!!!"


    1. Cutcode Автор
      23.11.2023 07:19

      Ваша ироничная история скорее всего про низкокачественный самогон, который сделан на скорую руку, для продажи. Это не про грамотно сделанный, качественный алкогольный напиток, сделанный для себя и друзей


  1. PQR
    23.11.2023 07:19
    +3

    Нужен обзор-сравнение MoonShine vs Filament! Интересно было бы на реализацию одного проекта на этих двух админках - реализовать одинаковый набор полей, несколько видов связей, взять всё типовое, что есть в обоих админках (на пересечении так сказать).

    И по итогу оценить получившийся код свежим взглядом, посчитав какие-нибудь метрики, например:
    - число классов, которые потребовалось написать в терминах конкретной админки;
    - число строк. Сомнительная метрика, но почему и нет? Написать эти строки всё-таки пришлось, значит какие-то трудозатраты и время. Чем меньше трудозатрат на штамповку админки, тем лучше фреймворк;
    - цикломатическая сложность. Хотя какая может быть цикломатическая сложность в почти декларативном описании админки?


    1. Cutcode Автор
      23.11.2023 07:19

      Отличное предложение. Вопрос как это реализовать? Вижу как это можно сделать:
      1 вариант - нужен незаинтересованный человек, который хорошо владеет MoonShine и Filament.
      2 вариант - небольшой челендж: по одному ТЗ разные разработчики выполняют проект на разных админках.
      Вы как видите реализацию такого сравнения?


  1. leon0399
    23.11.2023 07:19

    Серьезно, назвать админку - самогон - это мощно. Не, ну а реально, неужели получше названия не нашлось?)


    1. Cutcode Автор
      23.11.2023 07:19

      А как бы Вы назвали?