Action di Infolist
Sebuah infolist dapat menyediakan operasi di samping informasi yang ditampilkannya: menyetujui pesanan, mengirim ulang undangan, memverifikasi alamat. Semuanya menggunakan PandaPanel\Actions\Action yang sama seperti pada baris tabel atau header halaman, sehingga sebuah operasi terhadap record cukup dideskripsikan satu kali, terlepas dari dari mana operasi itu dijalankan.
Gunakan action ketika halaman view perlu melakukan sesuatu, bukan sekadar mengarahkan pengguna ke form edit untuk melakukannya.
Kumpulan action minimal
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Action;
use PandaPanel\Infolists\Components\TextEntry;
use PandaPanel\Infolists\InfolistSchema;
use PandaPanel\Infolists\Layouts\Section;
public static function infolist(InfolistSchema $schema): InfolistSchema
{
return $schema
->actions([ // the record as a whole
Action::make('approve')
->icon('check')
->requiresConfirmation()
->successMessage('Order approved.')
->action(static fn (Model $record) => $record->approve()),
])
->schema([
Section::make('Invitation')
->headerActions([ // this group of entries
Action::make('resend')
->label('Resend')
->icon('mail')
->action(static fn (Model $record) => $record->sendInvitation()),
])
->schema([
TextEntry::make('email')->action( // one value
Action::make('verify')
->icon('check')
->action(static fn (Model $record) => $record->markEmailAsVerified()),
),
]),
]);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Tiga tempat action dapat ditempatkan
| Lokasi | Dideklarasikan dengan | Ditampilkan sebagai |
|---|---|---|
| Record secara keseluruhan | InfolistSchema::actions() | Deretan tombol di atas infolist |
| Sekelompok entry | Section::headerActions() | Di header section, rata kanan |
| Satu nilai | Entry::action() | Tombol ikon kecil di samping label entry |
Posisi action menyampaikan maknanya. "Approve this order" berkaitan dengan record secara keseluruhan, jadi letakkan di atas semuanya; "resend invitation" berada di samping detail undangan; sedangkan "verify" berada di samping alamat yang diverifikasi.
InfolistSchema::actions()
public function actions(array $actions): self // array<array-key, Action>Action diserialisasi terhadap record. Karena itu, action yang tidak boleh dijalankan pengguna tidak dikirim sama sekali, alih-alih menampilkan tombol yang nantinya menjawab 403:
$schema->actions([
Action::make('approve')->authorize(static fn (?Model $record): bool => auth()->user()?->can('approve', $record) === true),
Action::make('cancel')->visible(static fn (?Model $record): bool => $record?->status === 'open'),
]);2
3
4
Section::headerActions()
public function headerActions(array $actions): self // array<array-key, Action>
public function getHeaderActions(): list<Action>2
Section::make('Invitation')
->headerActions([Action::make('resend')->icon('mail')->action(...)])
->schema([TextEntry::make('email')]);2
3
Entry::action()
public function action(Action $action): static
public function getAction(): ?Action2
Satu entry hanya memiliki satu action — setter akan mengganti action sebelumnya, bukan menambahkannya. Action dirender sebagai tombol icon-sm di samping label, jadi berikan icon():
TextEntry::make('api_key')->action(
Action::make('rotate')
->icon('rotate-ccw')
->requiresConfirmation(
heading: 'Rotate this key?',
description: 'Anything using the old key stops working immediately.',
button: 'Rotate it',
)
->action(static fn (Model $record) => $record->rotateApiKey()),
);2
3
4
5
6
7
8
9
10
Whitelist
InfolistSchema::allActions() mengumpulkan setiap action yang dideklarasikan schema dan mengindeksnya berdasarkan nama:
$schema = InfolistSchema::make()
->actions([Action::make('approve')])
->schema([
Section::make('Details')
->headerActions([Action::make('resend')])
->schema([TextEntry::make('name')->action(Action::make('rename'))]),
]);
array_keys($schema->allActions()); // ['approve', 'resend', 'rename']
$schema->getAction('resend'); // the Action, or null2
3
4
5
6
7
8
9
10
Map tersebut menjadi whitelist yang dipakai PandaPanel\Http\Controllers\PanelActionController::infolist() saat melakukan resolve. Action yang tidak ada di dalam map dianggap tidak ada, apa pun nama yang dikirim request.
Whitelist ini berbeda dari whitelist milik tabel. Action pada halaman view berasal dari Resource::infolist(). Jika lookup dilakukan pada schema tabel, action yang hanya ditampilkan di satu halaman dapat dijalankan dari halaman lain. Karena itu infolist memiliki endpoint tersendiri, bukan sekadar flag pada endpoint yang sudah ada.
Nama adalah key. Jika dua action di satu infolist memiliki nama yang sama, keduanya dianggap satu action dan deklarasi terakhir yang menang. Record action dan infolist action boleh memiliki nama yang sama karena keduanya dicari pada scope yang berbeda.
Action di dalam dialog
Action yang diregistrasikan pada modal milik action lain dapat dijangkau melalui parent-nya:
$schema->actions([
Action::make('approve')->registerModalActions([Action::make('explain')]),
]);
$schema->getAction('explain'); // not null2
3
4
5
allActions() berjalan satu level ke dalam getModalActions() setelah mengumpulkan action yang dideklarasikan. Nested action hanya ditambahkan jika namanya belum dipakai — registrasi pada parent inilah yang membuatnya dapat dijangkau.
Bagaimana action dijalankan
Composable frontend yang digunakan adalah useInfolistActions di resources/js/panel/composables/useInfolistActions.ts. Saat tombol ditekan, frontend melakukan POST ke endpoint infolist milik panel:
POST /{panel}/actions/infolist route name: panel.{id}.actions.infolist
{ "resource": "users", "action": "approve", "record": 42 }2
URL diberikan ke halaman sebagai actionEndpoints.infolist oleh ResourcePage::actionEndpoints(), sehingga Vue tidak pernah membangun URL panel sendiri.
Request hanya membawa nama, slug, dan key. Request tidak pernah membawa sesuatu yang executable. Server kemudian menjalankan langkah berikut secara berurutan:
| Langkah | Jika gagal |
|---|---|
Memvalidasi resource, action, record wajib ada | 422 |
| Me-resolve resource terhadap registry milik panel ini | 404 |
| Melakukan binding parent record jika resource bersifat nested | 404 |
Mencari action di Resource::infolist() | 404 Unknown action. |
Memeriksa isExecutable() — handler action() telah dideklarasikan | 400 This action cannot be executed. |
| Memastikan key record berupa string atau int | 422 Invalid record key. |
Mencari record melalui Resource::findRecord() | 404 |
Memeriksa kembali isAuthorizedFor($record) | 403 |
Menjalankan execute($record) | — |
Setelah itu back()->with('success', $action->getSuccessMessage()) dikembalikan, lalu panel mengubah flash tersebut menjadi toast.
Authorization sengaja diperiksa dua kali: pertama saat halaman dirender untuk menentukan apakah tombol boleh ditampilkan, dan kedua saat endpoint mengeksekusi action. Mendeskripsikan operasi dan benar-benar menjalankannya adalah dua momen yang berbeda.
Action yang memiliki form
Action dengan schema() membuka dialog. Form diambil saat dialog dibuka, bukan dikirim bersama payload halaman:
use PandaPanel\Actions\Enums\ModalWidth;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
Action::make('note')
->label('Add a note')
->icon('pencil')
->modalHeading('Note about this account')
->modalSubmitLabel('Save note')
->modalWidth(ModalWidth::Large)
->slideOver()
->successMessage('Note saved.')
->schema(static fn (?Model $record): FormSchema => FormSchema::make()->schema([
Textarea::make('note')->rows(6)->required()->maxLength(1000),
]))
->action(static function (Model $record, array $data): void {
logger()->info('Panel note', ['user' => $record->getKey(), 'note' => $data['note']]);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Dua request tambahan digunakan, dan keduanya membawa scope=infolist:
GET /{panel}/actions/form?resource=users&action=note&scope=infolist&record=42
POST /{panel}/actions/form { resource, action, scope: 'infolist', record, ...form data }2
scope adalah allowlist berisi record, table, bulk, dan infolist. Nilai ini menentukan schema tempat action dicari — sehingga form untuk infolist action hanya di-resolve dari Resource::infolist() dan bukan dari tempat lain.
Data yang dikirim divalidasi dan didehidrasi oleh FormSchema milik action sebelum handler menerimanya. Karena itu key tambahan pada request body dibuang sama seperti pada resource form. File field di dalam form juga mengunggah melalui endpoint yang diotorisasi oleh action, bukan resource: action yang tidak boleh dijalankan pengguna tidak boleh menjadi jalan untuk menyimpan file ke disk.
Lihat Action forms dan Modals.
Apa yang ditawarkan dan apa yang tidak
Action::toArray($record) mengembalikan null ketika action disembunyikan atau tidak terotorisasi untuk record tersebut. Entry::toArray() dan Section::toArray() sama-sama menyaring nilai null itu. Akibatnya halaman tidak pernah menampilkan tombol yang ketika ditekan hanya akan menghasilkan 403.
Dua kondisi tambahan hanya berlaku di dalam infolist:
'action' => $record->exists ? $this->action?->toArray($record) : null,- Entry di dalam repeatable tidak membawa action.
InfolistRowyang membungkus row tidak memiliki key, sehingga action yang menunjuk kepadanya akan menamai record yang tidak mungkin ditemukan endpoint. - Entry pada model yang belum disimpan juga tidak membawa action, dengan alasan yang sama.
Authorization
Ada dua closure, dan maknanya berbeda:
| Method | Signature | Makna |
|---|---|---|
visible() | visible(Closure $callback): static — Closure(?Model): bool | Apakah operasi ini relevan untuk record tersebut |
authorize() | authorize(Closure $callback): static — Closure(?Model): bool | Apakah pengguna ini boleh menjalankannya |
Action::make('resendVerification')
->visible(static fn (?Model $record): bool => $record?->email_verified_at === null)
->authorize(static fn (?Model $record): bool => auth()->user()?->is_admin === true);2
3
visible() tidak diperiksa ulang oleh endpoint; authorize() diperiksa ulang. Gunakan authorize() untuk semua hal yang merupakan permission — visible() hanya menyembunyikan tombol, sedangkan tombol yang tersembunyi bukanlah pengaman akses.
Testing
panelInfolistActions() adalah PandaPanel\Testing\TestsActions::infolist() yang dibatasi pada whitelist infolist. Setiap lookup melewati Resource::infolist(), yaitu schema yang sama dengan yang di-resolve controller. Jadi jika helper dapat menemukan sebuah action, endpoint juga dapat menemukannya:
use App\Models\User;
use App\Panels\Admin\Resources\Users\UserResource;
it('offers the note action to an administrator only', function (): void {
$record = User::factory()->create();
panelInfolistActions(UserResource::class)
->assertExists('note')
->assertDoesNotExist('purgeUnverified') // that one is a table action
->assertVisible('note', $record);
$this->actingAs(User::factory()->create(['is_admin' => false]));
panelInfolistActions(UserResource::class)->assertCanNotRun('note', $record);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
| Method | Signature | Yang diuji |
|---|---|---|
find() | find(string $name): ?Action | Tidak melakukan assertion; mengembalikan action atau null |
assertExists() | assertExists(string $name): self | Infolist mendeklarasikan action tersebut |
assertDoesNotExist() | assertDoesNotExist(string $name): self | Action tidak dideklarasikan |
assertVisible() | assertVisible(string $name, ?Model $record = null): self | Action visible dan authorized untuk record |
assertHidden() | assertHidden(string $name, ?Model $record = null): self | Action tidak ada atau tidak ditawarkan untuk record |
assertCanRun() | assertCanRun(string $name, ?Model $record = null): self | isAuthorizedFor() bernilai true |
assertCanNotRun() | assertCanNotRun(string $name, ?Model $record = null): self | Authorization bernilai false |
call() | call(string $name, ?Model $record = null, array $data = []): self | Melakukan authorization terlebih dahulu, lalu menjalankan action |
call() memeriksa authorization dan menggagalkan test jika pengguna tidak berhak, bukan melewati pemeriksaan tersebut. Helper yang tetap menjalankan action terlarang hanya membuktikan handler bekerja, bukan membuktikan action benar-benar dapat dijangkau pengguna.
Gotcha
- Section yang berada di dalam tab dapat mendeklarasikan header action yang tidak dapat ditemukan endpoint.
allActions()menelusuri component top-level schema untuk section. Section nested tetap menampilkan tombol, tetapi saat ditekan endpoint menjawab 404. Jika section bukan top-level, letakkan action pada schema melaluiactions()atau pada entry. - Action tanpa handler
action()menjawab 400.isExecutable()bernilaihandleUsing !== null. Link action — action denganurl()— dinavigasi langsung oleh browser dan tidak melakukan POST ke endpoint ini. - Endpoint infolist membutuhkan record key. Field
recordwajib ada. Action yang tidak bekerja terhadap record seharusnya berada pada table atau page header. Endpoint form menerima record null dan kemudian membutuhkan handlertableAction(), bukanaction()— memanggilexecuteWithoutRecord()dari halaman view memungkinkan, tetapi hampir selalu bukan yang dimaksud. - Deletion tidak memiliki page hook. Deletion berjalan melalui action endpoint tanpa instance page. Gunakan
Action::before()danAction::after(). - Success message adalah flash, bukan return value handler. Panel mengubah
back()->with('success', ...)menjadi toast, sehingga response yang dikembalikan handler akan diabaikan. - Nested modal action hanya dikumpulkan satu level. Action yang diregistrasikan pada action yang diregistrasikan lagi pada action lain tidak masuk ke
allActions().