Resource Pages
Setiap screen milik Resource adalah sebuah class yang disebut di dalam Resource::pages(). Empat key — index, create, view, edit — memiliki route tetap dan base class khusus. Key lain dianggap custom page yang Anda bentuk sendiri. Halaman ini membahas PandaPanel\Resources\Pages\ResourcePage, base class yang digunakan seluruh Resource page, serta kebutuhan custom page di luar empat page standar: route, record, heading, widget, dan metadata yang dikirim ke Vue.
Custom page
Dibutuhkan dua bagian: class page dan key yang mendaftarkan route-nya.
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts\Pages;
use App\Panels\Admin\Resources\Posts\PostResource;
use Inertia\Inertia;
use Inertia\Response;
use PandaPanel\Resources\Concerns\InteractsWithRecord;
use PandaPanel\Resources\Pages\ResourcePage;
final class AuditPost extends ResourcePage
{
use InteractsWithRecord;
protected static string $resource = PostResource::class;
protected static ?string $routePath = '{record}/audit';
protected static ?string $title = 'Audit';
public function render(string $record): Response
{
$post = $this->resolveRecord($record);
return Inertia::render('panel/Page', [
'page' => [
...$this->headingMetadata($post),
'breadcrumbs' => [],
'headerActions' => [],
'scope' => static::renderHookScope(),
],
'widgets' => [],
'revisions' => $post->revisions()->count(),
]);
}
}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
34
35
36
37
38
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListPosts::class,
'edit' => EditPost::class,
'audit' => AuditPost::class,
];
}2
3
4
5
6
7
8
9
10
11
Deklarasi tersebut mendaftarkan GET /admin/posts/{record}/audit dengan route name panel.admin.resources.posts.audit.
Apa itu sebuah Resource page
Page adalah controller. PandaPanel\Routing\PanelRouteRegistrar mendaftarkannya sebagai [PageClass::class, 'render'] untuk GET dan [PageClass::class, 'handle'] untuk write verb — tidak pernah menggunakan Closure. Inilah alasan php artisan route:cache tetap dapat bekerja.
Custom key mendaftarkan tepat satu route:
| Key | Verb | Path | Method | Route name |
|---|---|---|---|---|
Key selain index/create/view/edit | GET | ResourcePage::routePath($key) | render | panel.{id}.resources.{slug}.{key} |
Empat key standar dibahas di CRUD pages. Bentuk route-nya tetap dan tidak dapat dipindahkan.
ResourcePage sendiri tidak mendeklarasikan render() dan tidak menentukan Vue component. Custom page harus mengimplementasikan render() lalu memanggil Inertia::render() sendiri dengan component yang memang tersedia pada host application atau page set bawaan package. Lihat Inertia pages.
Deklarasi
Seluruh static property pada ResourcePage:
| Property | Type | Default | Arti |
|---|---|---|---|
$resource | class-string<Resource> | wajib | Resource yang memiliki page |
$routePath | ?string | null, berarti menggunakan page key | Path relatif terhadap Resource |
$title | ?string | null, berarti menggunakan default milik page | Document title |
$heading | ?string | null, berarti mengikuti title | Heading di atas content |
$subheading | ?string | null, berarti menggunakan default page | Baris di bawah heading |
$hasDatabaseTransactions | ?bool | null, berarti mengikuti setting Panel | Apakah write pada page memakai database transaction |
$resource adalah satu-satunya property tanpa default yang dapat berjalan. Property lain dapat dibiarkan apa adanya.
Route paths
public static function routePath(string $key): string; // static::$routePath ?? $key$routePath relatif terhadap prefix Resource dan boleh membawa {record} untuk page yang bekerja pada satu record. Registrar meneruskan segment tersebut ke render() seperti pada view dan edit page.
protected static ?string $routePath = '{record}/audit'; // /admin/posts/{record}/audit
protected static ?string $routePath = 'archive'; // /admin/posts/archive
protected static ?string $routePath = null; // /admin/posts/audit — the page key2
3
Path yang berbenturan dengan path Resource lain pada Panel yang sama menghasilkan PanelRegistrationException::collidingRoutePath() saat boot. Nama parameter dihapus sebelum shape dibandingkan, sehingga {record} dan {parentRecord} dianggap segment yang sama — Laravel juga tidak membedakan keduanya saat routing. Lihat Routing.
Mengakses record
Page yang route-nya membawa {record} menggunakan PandaPanel\Resources\Concerns\InteractsWithRecord:
use PandaPanel\Resources\Concerns\InteractsWithRecord;
protected function resolveRecord(int|string|null $key = null): Model; // resolves, authorizes, remembers
protected function getRecord(): Model; // throws LogicException if none
protected function hasRecord(): bool;
protected function authorizeRecord(Model $record): bool; // canView() by default2
3
4
5
6
Resolution melewati Resource::query() seperti seluruh lookup lain. Record di luar Resource scope menghasilkan 404 dan custom page tidak dapat menghindari boundary tersebut. Record disimpan untuk request berjalan, sehingga membacanya di tiga tempat tetap hanya menggunakan satu query.
Untuk meminta ability berbeda, override satu method:
use Illuminate\Database\Eloquent\Model;
protected function authorizeRecord(Model $record): bool
{
return static::$resource::canEdit($record);
}2
3
4
5
6
Check yang gagal menghasilkan 403 di dalam resolveRecord() sebelum page merender apa pun. Detail lengkap ada di Model binding.
Title, heading, dan subheading
public function getTitle(?Model $record = null): string;
public function getHeading(?Model $record = null): string;
public function getSubheading(?Model $record = null): ?string;2
3
Setiap getter membaca static property terlebih dahulu, lalu fallback ke default milik base page. Default-nya:
| Page | title | heading | subheading |
|---|---|---|---|
ListRecords | Plural label | title | — |
CreateRecord | New {label} | title | — |
ViewRecord | Record title | title | label |
EditRecord | Edit {record} | Record title | Edit {label} |
ManageRelatedRecords | Manager title | title | Owner record title |
ResourcePage | Plural label | title | — |
Custom page yang langsung meng-extend ResourcePage mewarisi plural label Resource sebagai title, sehingga page tetap memiliki heading walaupun tidak mendeklarasikannya. Heading mengikuti title kecuali page sengaja memisahkannya. Edit page adalah contoh utama karena breadcrumb sudah menjelaskan bahwa user sedang mengedit record.
Gunakan property ketika text bersifat tetap; override getter ketika text bergantung pada data runtime:
use Illuminate\Database\Eloquent\Model;
public function getSubheading(?Model $record = null): ?string
{
return $record === null
? null
: sprintf('%d revisions', $record->revisions()->count());
}2
3
4
5
6
7
8
Record diberikan pada page yang memiliki record dan bernilai null pada page lain, sehingga getter harus menangani kedua kondisi.
Mengambil ketiga metadata sekaligus
/**
* @return array{title: string, heading: string, subheading: string|null}
*/
protected function headingMetadata(?Model $record = null): array;2
3
4
Spread helper tersebut ke page metadata daripada memanggil tiga getter secara manual. Seluruh built-in page menggunakan pola ini sehingga fallback logic tidak perlu ditulis ulang.
Metadata yang dikirim page
Vue membaca satu prop page pada setiap Panel screen, dengan type PageMetadata: title, heading, subheading, breadcrumbs, headerActions, scope, subNavigation, cluster. ResourcePage menyediakan helper untuk setiap bagian.
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Support\Breadcrumb;
/**
* @return array<string, mixed>
*/
protected function pageMetadata(Model $record): array
{
return [
...$this->headingMetadata($record),
'breadcrumbs' => $this->serializeBreadcrumbs([
...$this->baseBreadcrumbs(),
$this->recordCrumb($record, $this->recordTitle($record)),
Breadcrumb::make('Audit')->current(),
]),
'headerActions' => [],
'scope' => static::renderHookScope(),
'cluster' => $this->clusterNavigation(),
'subNavigation' => $this->subNavigation($record, 'audit'),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| Helper | Signature | Yang diberikan |
|---|---|---|
baseBreadcrumbs() | protected function baseBreadcrumbs(): array | Dashboard → parent trail bila ada → Resource index |
parentBreadcrumbs() | protected function parentBreadcrumbs(): array | Trail owner; kosong jika Resource bukan nested |
recordCrumb() | protected function recordCrumb(Model $record, string $title): Breadcrumb | Crumb ke view page atau plain text jika view page tidak ada/tidak boleh dibuka |
serializeBreadcrumbs() | protected function serializeBreadcrumbs(array $crumbs): array | Array yang dibaca Vue |
recordTitle() | protected function recordTitle(Model $record): string | Resource::recordTitle() untuk Resource page ini |
resourceMetadata() | protected function resourceMetadata(): array | slug, label, pluralLabel, indexUrl, parentKey |
actionEndpoints() | protected function actionEndpoints(): array | Tujuh Action URL yang digunakan frontend untuk POST |
clusterNavigation() | protected function clusterNavigation(): ?array | Cluster bar atau null |
subNavigation() | protected function subNavigation(?Model $record, string $currentPage): array | Link antar-page record beserta posisinya |
panel() | protected function panel(): Panel | Current Panel atau PanelRegistrationException |
dashboardUrl() | protected function dashboardUrl(): string | Dashboard URL milik Panel |
relationTables() | protected function relationTables(Request $request, Model $record): array | Seluruh relation manager yang dideklarasikan Resource dan lolos authorization |
actionEndpoints() tersedia pada setiap Resource page, bukan hanya list page, karena infolist pada view page juga dapat membawa Action dan seluruh Action menggunakan endpoint set yang sama. Lihat Actions.
Sub-navigation
protected function subNavigation(?Model $record, string $currentPage): array;
protected function subNavigationPosition(): SubNavigationPosition;2
Item dibuat oleh PandaPanel\Support\RecordSubNavigation dari map pages() milik Resource: key view ketika canView() mengizinkan, key edit ketika canEdit() mengizinkan, ditambah setiap ManageRelatedRecords page yang manager-nya mengizinkan canViewAny(). Satu link bukan navigation, sehingga record yang hanya memiliki satu destination mendapat empty list.
Custom key tidak masuk ke sub-navigation. Internal map RecordSubNavigation hanya mengenal view dan edit, sedangkan relation page ditemukan melalui manager yang ditunjuknya. Custom page tidak memiliki ability standar yang dapat ditanyakan map tersebut. Tautkan custom page melalui header Action atau row Action pada table.
Posisi sub-navigation berasal dari Resource::subNavigationPosition() ketika Resource menyatakannya, atau dari setting Panel jika Resource tidak memiliki override. Lihat Sub-navigation.
Render hooks
public static function renderHookScope(): string; // 'resource:{slug}'Seluruh page milik satu Resource berbagi scope yang sama. Hook yang di-scope ke resource:posts muncul pada list, view, create, edit, dan custom page milik Resource tersebut. Scope menggunakan slug, bukan class name; page metadata tidak membawa PHP class name ke frontend. Lihat Render hooks.
Page widgets
Resource dapat mendeklarasikan widget sekali, lalu masing-masing Resource page dapat menaruh widget di atas atau di bawah content-nya.
use PandaPanel\Resources\Resource;
use PandaPanel\Widgets\Widget;
final class PostResource extends Resource
{
/** @return list<class-string<Widget>> */
public static function getWidgets(): array
{
return [PostsThisWeek::class]; // header of the index page
}
/** @return list<class-string<Widget>> */
public static function getHeaderWidgets(string $page): array
{
return $page === 'view'
? [PostEngagement::class]
: parent::getHeaderWidgets($page);
}
}
/**
* @return list<class-string<Widget>>
*/
public function headerWidgets(): array
{
return [
...parent::headerWidgets(),
DraftWarnings::class,
];
}
/**
* @return list<class-string<Widget>>
*/
public function footerWidgets(): array
{
return [];
}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
34
35
36
37
38
Resource::getWidgets() adalah shortcut untuk widget pada index page. Resource::getHeaderWidgets($page) dan Resource::getFooterWidgets($page) default ke behavior tersebut dan []. Method pada page dapat mengganti atau menggabungkan hasil dengan parent::headerWidgets() dan parent::footerWidgets().
Page mengubah daftar widget menjadi props melalui satu helper:
use PandaPanel\Widgets\PageContext;
protected function widgetProps(?PageContext $context = null): array;2
3
Return value berisi tiga key — headerWidgets, footerWidgets, widgetData — yang dibaca component Resource page. Spread langsung ke render:
return Inertia::render('panel/resources/View', [
'page' => $this->pageMetadata($record),
...$this->widgetProps(PageContext::forRecord($record)),
// ...
]);2
3
4
5
Context widget
PandaPanel\Widgets\PageContext membedakan page widget dengan dashboard widget. List page memberikan query miliknya sendiri; record page memberikan record.
use PandaPanel\Widgets\PageContext;
// What the page hands over.
PageContext::forRecord($record); // a record page
PageContext::forQuery(fn () => PostResource::query()); // a list page
// What the widget reads, through Widget::context().
$this->context()->record(); // ?Model — null on a list page
$this->context()->query(); // ?Builder — null on a record page
$this->context()->count(); // int — memoized, so three widgets share one query2
3
4
5
6
7
8
9
10
Widget::context() melempar exception jika widget dirender tanpa context yang dibutuhkannya. Widget yang mencoba membaca record padahal tidak pernah diberikan record berarti ditempatkan pada page yang salah; error eksplisit lebih berguna daripada count atau value 0 yang tampak valid. Lihat Widgets.
Page yang membawa form
Dua helper pada base class menyediakan pekerjaan dasar yang digunakan CreateRecord dan EditRecord, dan tersedia bagi custom page mana pun yang merender FormSchema.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use PandaPanel\Forms\FormSchema;
protected function fillForm(FormSchema $schema, ?Model $record = null): array;
protected function validateStepFor(Request $request, FormSchema $schema, ?Model $record = null): JsonResponse;2
3
4
5
6
7
fillForm() melakukan serialization schema dan menjalankan fill hooks — beforeFill, mutateFormDataBeforeFill, afterFill — terhadap flat map name => value. Dengan begitu page yang ingin mengubah satu field tidak perlu mengetahui nesting schema. Lihat Lifecycle hooks.
validateStepFor() memvalidasi satu Wizard step dan mengembalikan JSON. Form tanpa Wizard menghasilkan 400 daripada berpura-pura melakukan step validation, sedangkan step index di luar range menghasilkan 422.
Transactions
protected static ?bool $hasDatabaseTransactions = null;
public static function hasDatabaseTransactions(): ?bool;2
3
null berarti mengikuti setting Panel. Set value pada page yang write behavior-nya berbeda dari page lain — misalnya page yang memanggil external service dan tidak ingin menahan transaction terbuka terlalu lama. Persist step dan hook after* berada di transaction yang sama, sehingga exception pada hook dapat melakukan rollback write.
Relation pages
PandaPanel\Resources\Pages\ManageRelatedRecords memberikan salah satu relation milik record page sendiri — manager yang sama dengan yang biasanya ditampilkan inline, tetapi sekarang memiliki route dan tempat pada record sub-navigation.
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts\Pages;
use App\Panels\Admin\Resources\Posts\PostResource;
use App\Panels\Admin\Resources\Posts\RelationManagers\CommentsRelationManager;
use PandaPanel\Resources\Pages\ManageRelatedRecords;
final class ManagePostComments extends ManageRelatedRecords
{
protected static string $resource = PostResource::class;
protected static string $relationManager = CommentsRelationManager::class;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static function pages(): array
{
return [
'index' => ListPosts::class,
'view' => ViewPost::class,
'edit' => EditPost::class,
'comments' => ManagePostComments::class,
];
}2
3
4
5
6
7
8
9
| Member | Signature | Behavior |
|---|---|---|
$relationManager | protected static string | Wajib. Manager yang ditampilkan page |
relationManager() | public static function relationManager(): string | Mengembalikan class di atas |
relationPageKey() | public static function relationPageKey(): string | $relationManager::key(), identity yang dipakai sub-navigation |
routePath() | public static function routePath(string $key): string | '{record}/'.$key kecuali $routePath menentukan value lain |
Relation page bukan registration kedua. Page menghasilkan 404 kecuali Resource::relationManagers() sudah mendeklarasikan manager tersebut. Dengan demikian page tidak dapat menjadi cara mengakses relation yang Resource sendiri tidak pernah daftarkan. Page merender panel/resources/ManageRelated dan menghasilkan 403 ketika manager menolak owner.
php artisan make:panel-relation-manager comments --panel=Admin --resource=Post --page membuat manager dan page ini bersama-sama. Lihat Relation pages.
Beberapa record page
Beberapa page pada satu record berarti beberapa key. Setiap key mendapatkan route name dan path sendiri:
public static function pages(): array
{
return [
'index' => ListPosts::class,
'view' => ViewPost::class,
'edit' => EditPost::class,
'audit' => AuditPost::class, // GET {record}/audit
'history' => PostHistory::class, // GET {record}/history
'comments' => ManagePostComments::class,
];
}2
3
4
5
6
7
8
9
10
11
Semua page berbagi Resource scope, label, policy, dan render hook scope. Bagian lain tidak harus sama.
Catatan penting
- Custom page tidak memiliki route
handle(). Hanya satu GET route dibuat untuk setiap custom key. Page yang perlu melakukan write sebaiknya melakukan POST ke Action endpoint atau route aplikasi milik Anda sendiri. ResourcePagetidak menentukan Vue component. Empat built-in page masing-masing mendeklarasikan$component; custom page memberikan component langsung padaInertia::render().render()menerima route parameter berdasarkan nama. Page dengan$routePathberisi{record}dapat mendeklarasikanrender(string $record), ataurender(Request $request, ?string $record = null)jika juga membutuhkan request object.- Memanggil
getRecord()sebelumresolveRecord()menghasilkanLogicException. Resolve record satu kali di awalrender(). - Custom page tidak otomatis masuk record sub-navigation. Hanya
view,edit, dan relation page yang masuk. - Dua Resource yang mengklaim path shape sama gagal saat boot, bukan saat request — termasuk collision antara
ManageRelatedRecordspage dan nested Resource. Dua page pada Resource yang sama yang memiliki shape sama tidak melempar exception karena claim dicatat atas nama Resource; salah satu page akan diam-diam menutupi page lain. - Widget props terdiri dari tiga key, bukan satu. Component yang hanya membaca
widgetssepertipanel/Pagetidak otomatis melihatheaderWidgetsdanfooterWidgets.