- Форма и события
- Компонент представления с AlpineJs
- Подключение сборки Vite
- Пользовательские кнопки
- HasOne через поле Template
- Изменение хлебных крошек из ресурса
- Индексная страница через CardsBuilder
- Сортировка для CardsBuilder
- updateOnPreview для полей pivot
- ID родителя в HasMany
- Количество символов TinyMce в предпросмотре
- Изменение логики поведения поля Image для сохранения путей к изображениям в отдельной таблице базы данных
- Сохранение изображений в связанной таблице
- Пользовательский фильтр выбора
- Асинхронные метрики
Форма и события
При успешном запросе форма обновляет таблицу и сбрасывает значения.
Block::make([FormBuilder::make(route('form-table.store'))->fields([Text::make('Title')])->name('main-form')->async(asyncEvents: ['table-updated-main-table','form-reset-main-form'])]),TableBuilder::make()->fields([ID::make(),Text::make('Title'),Textarea::make('Body'),])->creatable()->items(Post::query()->paginate())->name('main-table')->async()
Block::make([FormBuilder::make(route('form-table.store'))->fields([Text::make('Title')])->name('main-form')->async(asyncEvents: ['table-updated-main-table','form-reset-main-form'])]),TableBuilder::make()->fields([ID::make(),Text::make('Title'),Textarea::make('Body'),])->creatable()->items(Post::query()->paginate())->name('main-table')->async()
Давайте также рассмотрим, как добавить свои собственные события
<div x-data=""@my-event.window="alert()"></div>
<div x-data=""@my-event.window="alert()"></div>
<div x-data="my"@my-event.window="asyncRequest"></div><script>document.addEventListener("alpine:init", () => {Alpine.data("my", () => ({init() {},asyncRequest() {this.$event.preventDefault()// this.$el// this.$root}}))})</script>
<div x-data="my"@my-event.window="asyncRequest"></div><script>document.addEventListener("alpine:init", () => {Alpine.data("my", () => ({init() {},asyncRequest() {this.$event.preventDefault()// this.$el// this.$root}}))})</script>
FormBuilder::make(route('form-table.store'))->fields([Text::make('Title')])->name('main-form')->async(asyncEvents: ['my-event'])
FormBuilder::make(route('form-table.store'))->fields([Text::make('Title')])->name('main-form')->async(asyncEvents: ['my-event'])
Компонент представления с AlpineJs
Мы также рекомендуем вам ознакомиться с AlpineJs и использовать всю мощь этого js-фреймворка.
Вы можете использовать его реактивность, давайте посмотрим, как удобно создать компонент.
<div x-data="myComponent"></div><script>document.addEventListener("alpine:init", () => {Alpine.data("myComponent", () => ({init() {},}))})</script>
<div x-data="myComponent"></div><script>document.addEventListener("alpine:init", () => {Alpine.data("myComponent", () => ({init() {},}))})</script>
Подключение сборки Vite
Давайте добавим одну скомпилированную с помощью Vite сборку.
public function indexButtons(): array{$resource = new CommentResource();return [ActionButton::make('Custom button', static fn ($data): string => to_page(page: $resource->formPage(),resource: $resource,params: ['resourceItem' => $data->getKey()]))];}
public function indexButtons(): array{$resource = new CommentResource();return [ActionButton::make('Custom button', static fn ($data): string => to_page(page: $resource->formPage(),resource: $resource,params: ['resourceItem' => $data->getKey()]))];}
Пользовательские кнопки
Давайте добавим пользовательские кнопки в индексную таблицу.
public function indexButtons(): array{$resource = new CommentResource();return [ActionButton::make('Custom button', static fn ($data): string => to_page(page: $resource->formPage(),resource: $resource,params: ['resourceItem' => $data->getKey()]))];}
public function indexButtons(): array{$resource = new CommentResource();return [ActionButton::make('Custom button', static fn ($data): string => to_page(page: $resource->formPage(),resource: $resource,params: ['resourceItem' => $data->getKey()]))];}
HasOne через поле Template
Пример реализации отношения HasOne через поле Template
.
use MoonShine\Fields\Template;//...public function fields(): array{return [Template::make('Comment')->changeFill(fn (Article $data) => $data->comment)->changePreview(fn($data) => $data?->id ?? '-')->fields((new CommentResource())->getFormFields())->changeRender(function (?Comment $data, Template $field) {$fields = $field->preparedFields();$fields->fill($data?->toArray() ?? [], $data ?? new Comment());return Components::make($fields);})->onAfterApply(function (Article $item, array $value) {$item->comment()->updateOrCreate(['id' => $value['id']], $value);return $item;})];}//...
use MoonShine\Fields\Template;//...public function fields(): array{return [Template::make('Comment')->changeFill(fn (Article $data) => $data->comment)->changePreview(fn($data) => $data?->id ?? '-')->fields((new CommentResource())->getFormFields())->changeRender(function (?Comment $data, Template $field) {$fields = $field->preparedFields();$fields->fill($data?->toArray() ?? [], $data ?? new Comment());return Components::make($fields);})->onAfterApply(function (Article $item, array $value) {$item->comment()->updateOrCreate(['id' => $value['id']], $value);return $item;})];}//...
Изменение хлебных крошек из ресурса
Вы можете изменить хлебные крошки страницы непосредственно из ресурса.
namespace App\MoonShine\Resources;use App\Models\Post;use MoonShine\Resources\ModelResource;class PostResource extends ModelResource{//...protected function onBoot(): void{$this->formPage()->setBreadcrumbs(['#' => $this->title()]);}//...}
namespace App\MoonShine\Resources;use App\Models\Post;use MoonShine\Resources\ModelResource;class PostResource extends ModelResource{//...protected function onBoot(): void{$this->formPage()->setBreadcrumbs(['#' => $this->title()]);}//...}
Индексная страница через CardsBuilder
Давайте изменим отображение элементов на индексной странице через компонент CardsBuilder.
class MoonShineUserIndexPage extends IndexPage{public function listComponentName(): string{return 'index-cards';}public function listEventName(): string{return 'cards-updated';}protected function itemsComponent(iterable $items, Fields $fields): MoonShineRenderable{return CardsBuilder::make($items, $fields)->cast($this->getResource()->getModelCast())->name($this->listComponentName())->async()->overlay()->title('email')->subtitle('name')->url(fn ($user) => $this->getResource()->formPageUrl($user))->thumbnail(fn ($user) => asset($user->avatar))->buttons($this->getResource()->getIndexItemButtons());}}
class MoonShineUserIndexPage extends IndexPage{public function listComponentName(): string{return 'index-cards';}public function listEventName(): string{return 'cards-updated';}protected function itemsComponent(iterable $items, Fields $fields): MoonShineRenderable{return CardsBuilder::make($items, $fields)->cast($this->getResource()->getModelCast())->name($this->listComponentName())->async()->overlay()->title('email')->subtitle('name')->url(fn ($user) => $this->getResource()->formPageUrl($user))->thumbnail(fn ($user) => asset($user->avatar))->buttons($this->getResource()->getIndexItemButtons());}}
Сортировка для CardsBuilder
Давайте создадим сортировку для компонента CardsBuilder:
Select::make('Sorts')->options(['created_at' => 'Date','id' => 'ID',])->onChangeMethod('reSort', events: ['cards-updated-cards'])->setValue(session('sort_column') ?: 'created_at'),CardsBuilder::make(items: Article::query()->with('author')->when(session('sort_column'),fn($q) => $q->orderBy(session('sort_column'), session('sort_direction', 'asc')),fn($q) => $q->latest())->paginate())->name('cards')->async()->cast(ModelCast::make(Article::class))->title('title')->url(fn($data) => (new ArticleResource())->formPageUrl($data))->overlay()->columnSpan(4) ,// ...public function reSort(MoonShineRequest $request): void{session()->put('sort_column', $request->get('value'));session()->put('sort_direction', 'ASC');}
Select::make('Sorts')->options(['created_at' => 'Date','id' => 'ID',])->onChangeMethod('reSort', events: ['cards-updated-cards'])->setValue(session('sort_column') ?: 'created_at'),CardsBuilder::make(items: Article::query()->with('author')->when(session('sort_column'),fn($q) => $q->orderBy(session('sort_column'), session('sort_direction', 'asc')),fn($q) => $q->latest())->paginate())->name('cards')->async()->cast(ModelCast::make(Article::class))->title('title')->url(fn($data) => (new ArticleResource())->formPageUrl($data))->overlay()->columnSpan(4) ,// ...public function reSort(MoonShineRequest $request): void{session()->put('sort_column', $request->get('value'));session()->put('sort_direction', 'ASC');}
updateOnPreview для полей pivot
Реализация через asyncMethod метода для изменения pivot поля на индексной странице:
public function fields(): array{return [Grid::make([Column::make([ID::make()->sortable(),Text::make('Team title')->required(),Number::make('Team number'),BelongsTo::make('Tournament', resource: new TournamentResource())->searchable(),]),Column::make([BelongsToMany::make('Users', resource: new UserResource())->fields([Switcher::make('Approved')->updateOnPreview(MoonShineRouter::asyncMethodClosure('updatePivot',params: fn($data) => ['parent' => $data->pivot->tournamen_team_id])),])->searchable(),])])];}public function updatePivot(MoonShineRequest $request): MoonShineJsonResponse{$item = TournamentTeam::query()->findOrFail($request->get('parent'));$column = (string) $request->str('field')->remove('pivot.');$item->users()->updateExistingPivot($request->get('resourceItem'), [$column => $request->get('value'),]);return MoonShineJsonResponse::make()->toast('Success');}
public function fields(): array{return [Grid::make([Column::make([ID::make()->sortable(),Text::make('Team title')->required(),Number::make('Team number'),BelongsTo::make('Tournament', resource: new TournamentResource())->searchable(),]),Column::make([BelongsToMany::make('Users', resource: new UserResource())->fields([Switcher::make('Approved')->updateOnPreview(MoonShineRouter::asyncMethodClosure('updatePivot',params: fn($data) => ['parent' => $data->pivot->tournamen_team_id])),])->searchable(),])])];}public function updatePivot(MoonShineRequest $request): MoonShineJsonResponse{$item = TournamentTeam::query()->findOrFail($request->get('parent'));$column = (string) $request->str('field')->remove('pivot.');$item->users()->updateExistingPivot($request->get('resourceItem'), [$column => $request->get('value'),]);return MoonShineJsonResponse::make()->toast('Success');}
ID родителя в HasMany
Связь HasMany хранит данные файлов, которые нужно сохранить в директории по id родителя.
use App\Models\PostImage;use MoonShine\Fields\ID;use MoonShine\Fields\Image;use MoonShine\Fields\Relationships\BelongsTo;use MoonShine\Resources\ModelResource;use MoonShine\Traits\Resource\ResourceWithParent;class PostImageResource extends ModelResource{use ResourceWithParent;public string $model = PostImage::class;protected function getParentResourceClassName(): string{return PostResource::class;}protected function getParentRelationName(): string{return 'post';}public function fields(): array{return [ID::make(),Image::make('Path')->when($parentId = $this->getParentId(),fn(Image $image) => $image->dir('post_images/'.$parentId)),BelongsTo::make('Post', 'post', resource: new PostResource())];}//...}
use App\Models\PostImage;use MoonShine\Fields\ID;use MoonShine\Fields\Image;use MoonShine\Fields\Relationships\BelongsTo;use MoonShine\Resources\ModelResource;use MoonShine\Traits\Resource\ResourceWithParent;class PostImageResource extends ModelResource{use ResourceWithParent;public string $model = PostImage::class;protected function getParentResourceClassName(): string{return PostResource::class;}protected function getParentRelationName(): string{return 'post';}public function fields(): array{return [ID::make(),Image::make('Path')->when($parentId = $this->getParentId(),fn(Image $image) => $image->dir('post_images/'.$parentId)),BelongsTo::make('Post', 'post', resource: new PostResource())];}//...}
Количество символов TinyMce в предпросмотре
Иногда необходимо отобразить поле TinyMce в предпросмотре с ограниченным количеством символов. Для этого можно использовать метод changePreview()
.
public function fields(): array{return [TinyMce::make('Description')->changePreview(fn(string $text) => str($text)->stripTags()->limit(10))];}//...}
public function fields(): array{return [TinyMce::make('Description')->changePreview(fn(string $text) => str($text)->stripTags()->limit(10))];}//...}
Изменение логики поведения поля Image для сохранения путей к изображениям в отдельной таблице базы данных
- Для решения этой задачи необходимо заблокировать метод
onApply()
и перенести логику вonAfterApply()
. Это позволит получить родительскую модель на странице создания. У нас будет доступ к модели, и мы сможем работать с ее отношениями. - Метод
onAfterApply()
сохраняет и получает старые и текущие значения, а также очищает удаленные файлы. - После удаления родительской записи метод
onAfterDestroy()
удаляет загруженные файлы.
use MoonShine\Fields\Image;//...Image::make('Images', 'images')->multiple()->removable()->changeFill(function (Model $data, Image $field) {// return $data->images->pluck('file');// или rawreturn DB::table('images')->pluck('file');})->onApply(function (Model $data) {// блокируем onApplyreturn $data;})->onAfterApply(function (Model $data, false|array $values, Image $field) {// $field->getRemainingValues(); значения, которые остались в форме с учетом удалений// $field->toValue(); текущие изображения// $field->toValue()->diff($field->getRemainingValues()) удаленные изображенияif($values !== false) {foreach ($values as $value) {DB::table('images')->insert(['file' => $field->store($value),]);}}foreach ($field->toValue()->diff($field->getRemainingValues()) as $removed) {DB::table('images')->where('file', $removed)->delete();Storage::disk('public')->delete($removed);}// или $field->removeExcludedFiles();return $data;})->onAfterDestroy(function (Model $data, mixed $values, Image $field) {foreach ($values as $value) {Storage::disk('public')->delete($value);}return $data;})//...
use MoonShine\Fields\Image;//...Image::make('Images', 'images')->multiple()->removable()->changeFill(function (Model $data, Image $field) {// return $data->images->pluck('file');// или rawreturn DB::table('images')->pluck('file');})->onApply(function (Model $data) {// блокируем onApplyreturn $data;})->onAfterApply(function (Model $data, false|array $values, Image $field) {// $field->getRemainingValues(); значения, которые остались в форме с учетом удалений// $field->toValue(); текущие изображения// $field->toValue()->diff($field->getRemainingValues()) удаленные изображенияif($values !== false) {foreach ($values as $value) {DB::table('images')->insert(['file' => $field->store($value),]);}}foreach ($field->toValue()->diff($field->getRemainingValues()) as $removed) {DB::table('images')->where('file', $removed)->delete();Storage::disk('public')->delete($removed);}// или $field->removeExcludedFiles();return $data;})->onAfterDestroy(function (Model $data, mixed $values, Image $field) {foreach ($values as $value) {Storage::disk('public')->delete($value);}return $data;})//...
В коде закомментирован вариант с отношением и приведен пример нативного получения путей к файлам из другой таблицы.
Сохранение изображений в связанной таблице
use Illuminate\Database\Eloquent\Model;use Illuminate\Support\Facades\DB;use Illuminate\Support\Facades\Storage;use MoonShine\Fields\Image;// ...Image::make('Images', 'images')->multiple()->removable()->changeFill(function (Model $data, Image $field) {// return $data->images->pluck('file');// или rawreturn DB::table('images')->pluck('file');})->onApply(function (Model $data) {// блокируем onApplyreturn $data;})->onAfterApply(function (Model $data, false|array $values, Image $field) {// $field->getRemainingValues(); значения, которые остались в форме с учетом удалений// $field->toValue(); текущие изображения// $field->toValue()->diff($field->getRemainingValues()) удаленные изображенияif($values !== false) {foreach ($values as $value) {DB::table('images')->insert(['file' => $field->store($value),]);}}foreach ($field->toValue()->diff($field->getRemainingValues()) as $removed) {DB::table('images')->where('file', $removed)->delete();Storage::disk('public')->delete($removed);}// или $field->removeExcludedFiles();return $data;})->onAfterDestroy(function (Model $data, mixed $values, Image $field) {foreach ($values as $value) {Storage::disk('public')->delete($value);}return $data;})
use Illuminate\Database\Eloquent\Model;use Illuminate\Support\Facades\DB;use Illuminate\Support\Facades\Storage;use MoonShine\Fields\Image;// ...Image::make('Images', 'images')->multiple()->removable()->changeFill(function (Model $data, Image $field) {// return $data->images->pluck('file');// или rawreturn DB::table('images')->pluck('file');})->onApply(function (Model $data) {// блокируем onApplyreturn $data;})->onAfterApply(function (Model $data, false|array $values, Image $field) {// $field->getRemainingValues(); значения, которые остались в форме с учетом удалений// $field->toValue(); текущие изображения// $field->toValue()->diff($field->getRemainingValues()) удаленные изображенияif($values !== false) {foreach ($values as $value) {DB::table('images')->insert(['file' => $field->store($value),]);}}foreach ($field->toValue()->diff($field->getRemainingValues()) as $removed) {DB::table('images')->where('file', $removed)->delete();Storage::disk('public')->delete($removed);}// или $field->removeExcludedFiles();return $data;})->onAfterDestroy(function (Model $data, mixed $values, Image $field) {foreach ($values as $value) {Storage::disk('public')->delete($value);}return $data;})
Пользовательский фильтр выбора
namespace App\MoonShine\Resources;use MoonShine\Resources\ModelResource;class PostResource extends ModelResource{//...public function filters(): array{return [Select::make('Activity status', 'active')->options(['0' => 'Only NOT active','1' => 'Only active',])->nullable()->onApply(fn(Builder $query, $value) => $query->where('active', $value)),];}//...}
namespace App\MoonShine\Resources;use MoonShine\Resources\ModelResource;class PostResource extends ModelResource{//...public function filters(): array{return [Select::make('Activity status', 'active')->options(['0' => 'Only NOT active','1' => 'Only active',])->nullable()->onApply(fn(Builder $query, $value) => $query->where('active', $value)),];}//...}
Асинхронные метрики
Метрики с параметрами формы
$startDate = request()->date('_form.start_date');$endDate = request()->date('_form.end_date');FormBuilder::make()->dispatchEvent(AlpineJs::event(JsEvent::FRAGMENT_UPDATED, 'metrics'))->fields([Flex::make([Date::make('Start date'),Date::make('End date'),]),]),Fragment::make([FlexibleRender::make("$startDate - $endDate"),LineChartMetric::make('Orders')->line(['Profit' => Order::query()->selectRaw('SUM(price) as sum, DATE_FORMAT(created_at, "%d.%m.%Y") as date')->whereBetween('created_at', [$startDate, $endDate])->groupBy('date')->pluck('sum', 'date')->toArray(),])->line(['Avg' => Order::query()->selectRaw('AVG(price) as avg, DATE_FORMAT(created_at, "%d.%m.%Y") as date')->whereBetween('created_at', [$startDate, $endDate])->groupBy('date')->pluck('avg', 'date')->toArray(),], '#EC4176'),])->name('metrics'),
$startDate = request()->date('_form.start_date');$endDate = request()->date('_form.end_date');FormBuilder::make()->dispatchEvent(AlpineJs::event(JsEvent::FRAGMENT_UPDATED, 'metrics'))->fields([Flex::make([Date::make('Start date'),Date::make('End date'),]),]),Fragment::make([FlexibleRender::make("$startDate - $endDate"),LineChartMetric::make('Orders')->line(['Profit' => Order::query()->selectRaw('SUM(price) as sum, DATE_FORMAT(created_at, "%d.%m.%Y") as date')->whereBetween('created_at', [$startDate, $endDate])->groupBy('date')->pluck('sum', 'date')->toArray(),])->line(['Avg' => Order::query()->selectRaw('AVG(price) as avg, DATE_FORMAT(created_at, "%d.%m.%Y") as date')->whereBetween('created_at', [$startDate, $endDate])->groupBy('date')->pluck('avg', 'date')->toArray(),], '#EC4176'),])->name('metrics'),