Custom Page
PandaPanel\Pages\Page adalah screen Panel yang bukan Resource: tidak memiliki model, record, atau table. Gunakan ketika Panel membutuhkan sesuatu di luar CRUD — misalnya settings screen, report, dashboard kedua, atau import console — tetapi Anda tetap ingin shell, navigation entry, breadcrumb, header, dan authorization yang sama seperti screen Panel lainnya.
Sebuah Page tidak wajib memiliki file Vue. Membiarkan $component pada default akan merender generic page shell, sehingga Page berguna paling sederhana cukup berupa class dengan title.
Contoh minimal yang berfungsi
php artisan make:panel-page Settings --panel=Admin<?php
declare(strict_types=1);
namespace App\Panels\Admin\Pages;
use BackedEnum;
use PandaPanel\Pages\Page;
final class Settings extends Page
{
protected static ?string $title = 'Settings';
protected static ?string $subheading = 'Application-wide configuration.';
protected static ?string $navigationIcon = 'settings';
protected static string|BackedEnum|null $navigationGroup = 'System';
protected static int $navigationSort = 100;
/**
* @return array<string, mixed>
*/
public function props(): array
{
return [
'settings' => [
['label' => 'Environment', 'value' => app()->environment()],
['label' => 'Timezone', 'value' => (string) config('app.timezone')],
],
];
}
}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
Jika Panel memiliki discoverPages(app_path('Panels/Admin/Pages')), setup selesai. Page tersedia di /admin/settings, route-nya bernama panel.admin.pages.settings, terdaftar di sidebar di bawah group System, dan dirender oleh resources/js/pages/panel/Page.vue.
Route
Setiap Page memiliki satu GET route yang didaftarkan oleh PandaPanel\Routing\PanelRouteRegistrar di dalam route group Panel.
| Bagian | Nilai | Berasal dari |
|---|---|---|
| Route name | panel.{panelId}.pages.{slug} | Page::slug() |
| Path | {panelPath}/{routePath} | Page::routePath() |
| Controller | PandaPanel\Http\Controllers\PanelPageController | fixed |
| Middleware | stack milik Panel, ditambah Page::middleware() | $middleware |
Class Page diikat ke route defaults, bukan dibaca dari URL. Karena itu controller tidak pernah me-resolve nama class dari request. Controller hanya melakukan satu hal:
return (new $page)->render();slug() dan routePath() sengaja dipisahkan. Slug adalah bagian route name sekaligus registry key, sedangkan path adalah yang tampil di address bar. Override routePath() untuk meletakkan Page pada nested path tanpa mengubah slug menjadi beberapa segment — inilah yang dilakukan built-in settings pages:
use PandaPanel\Pages\Page;
final class ProfileSettings extends Page
{
protected static ?string $slug = 'settings-profile';
public static function routePath(): string
{
return 'settings/profile';
}
}2
3
4
5
6
7
8
9
10
11
Page tersebut tersedia di /admin/settings/profile tetapi route name-nya tetap panel.admin.pages.settings-profile.
Static property
Semuanya protected static dan semuanya memiliki default yang dapat langsung dipakai.
| Property | Type | Default | Efek |
|---|---|---|---|
$title | ?string | Str::headline(class_basename()) | title browser tab sekaligus fallback untuk bagian lain |
$heading | ?string | title() | <h1> di atas content |
$subheading | ?string | null | baris di bawah heading |
$slug | ?string | Str::kebab(class_basename()) | suffix route name dan registry key |
$component | string | 'panel/Page' | Inertia component yang dirender |
$navigationLabel | ?string | title() | label sidebar |
$navigationIcon | ?string | null | icon registry key |
$activeNavigationIcon | ?string | $navigationIcon | icon saat item active |
$navigationGroup | string|BackedEnum|null | null | heading sidebar; null adalah bucket ungrouped |
$navigationSort | int | 0 | urutan di dalam group |
$shouldRegisterNavigation | bool | true | false mempertahankan route tetapi menghapus sidebar entry |
$cluster | class-string<Cluster>|null | null | cluster tempat Page berada |
$middleware | list<string> | [] | ditambahkan ke route Page |
use BackedEnum;
use Illuminate\Auth\Middleware\RequirePassword;
use PandaPanel\Clusters\Cluster;
use PandaPanel\Pages\Page;
final class AuditLog extends Page
{
protected static ?string $title = 'Audit log';
protected static ?string $heading = 'Recent activity';
protected static ?string $subheading = 'Everything written in the last 30 days.';
protected static ?string $slug = 'audit';
protected static string $component = 'Panels/Admin/Pages/AuditLog';
protected static ?string $navigationLabel = 'Audit';
protected static ?string $navigationIcon = 'shield';
protected static ?string $activeNavigationIcon = 'shield-check';
protected static string|BackedEnum|null $navigationGroup = 'System';
protected static int $navigationSort = 20;
protected static bool $shouldRegisterNavigation = true;
/** @var class-string<Cluster>|null */
protected static ?string $cluster = null;
/** @var list<string> */
protected static array $middleware = [RequirePassword::class];
}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
$middleware ada untuk kebutuhan yang tidak dapat diekspresikan authorization boolean. canAccess() hanya menjawab yes atau no; mengubah "konfirmasikan password terlebih dahulu" menjadi pemeriksaan canAccess() akan menghasilkan 403, padahal user membutuhkan redirect ke confirmation screen. Lihat Page authorization.
Static method
public static function slug(): string;
public static function routePath(): string;
public static function cluster(): ?string;
public static function title(): string;
public static function heading(): string;
public static function activeNavigationIcon(): ?string;
public static function middleware(): array; // list<string>
public static function canAccess(): bool;
public static function navigationItem(PanelContract $panel): ?NavigationItem;
public static function routeName(Panel|string|null $panel = null): string;
public static function url(Panel|string|null $panel = null): string;
public static function renderHookScope(): string;2
3
4
5
6
7
8
9
10
11
12
use App\Panels\Admin\Pages\Settings;
Settings::slug(); // 'settings'
Settings::routePath(); // 'settings'
Settings::title(); // 'Settings'
Settings::heading(); // 'Settings'
Settings::routeName('admin');// 'panel.admin.pages.settings'
Settings::url('admin'); // '/admin/settings'
Settings::renderHookScope(); // 'page:settings'2
3
4
5
6
7
8
9
routeName() dan url() menerima object Panel, panel id, atau tidak menerima argument. Tanpa argument berarti menggunakan Panel yang di-resolve untuk request saat ini; di luar request Panel kondisi itu melempar PandaPanel\Exceptions\PanelRegistrationException. url() selalu berbasis route name, sehingga perubahan path Panel memindahkan semua link sekaligus. Lihat Full page URLs.
renderHookScope() berupa slug, bukan nama class — metadata Page tidak boleh membawa nama PHP class. Lihat Render hooks.
Instance method
public function props(): array; // array<string, mixed>
public function widgets(): array; // list<class-string<Widget>>
public function breadcrumbs(): array; // list<Breadcrumb>
public function headerActions(): array; // list<array<string, mixed>>
public function filterSchema(): ?FormSchema;
public function render(): Inertia\Response;
protected function metadata(): array; // array<string, mixed>
protected function filterSessionKey(): string;
protected function resolveFilters(): WidgetFilters;
protected function resolveWidgets(?WidgetFilters $filters = null): WidgetCollection;
protected function panel(): Panel;
protected function dashboardUrl(): string;
protected static function resolvePanel(Panel|string|null $panel): Panel;2
3
4
5
6
7
8
9
10
11
12
13
14
props()
Hanya gunakan value yang dapat diserialisasi. Value dari props() di-spread paling akhir ke Inertia response, sehingga Page prop dapat menimpa key framework dengan nama yang sama.
use App\Models\Invoice;
/**
* @return array<string, mixed>
*/
public function props(): array
{
return [
'outstanding' => Invoice::query()->whereNull('paid_at')->count(),
'currency' => config('app.currency'),
];
}2
3
4
5
6
7
8
9
10
11
12
widgets()
Daftar class Widget sesuai urutan tampil. Page dapat menjadi host Widget dengan cara yang sama seperti Dashboard karena Dashboard sendiri merupakan sebuah Page.
use App\Panels\Admin\Widgets\RecentUsers;
use App\Panels\Admin\Widgets\UserStats;
use PandaPanel\Widgets\Widget;
/**
* @return list<class-string<Widget>>
*/
public function widgets(): array
{
return [UserStats::class, RecentUsers::class];
}2
3
4
5
6
7
8
9
10
11
PandaPanel\Pages\WidgetCollection memfilter Widget::canView() sebelum melakukan instantiation, sehingga widget unauthorized tidak pernah menjalankan query, lalu hasilnya diurutkan berdasarkan [sort, id]. Lazy widget mengirim definition dengan data null ditambah satu deferred prop yang membawa seluruh lazy payload. Lihat Widgets dan Lazy loading.
breadcrumbs()
Mengembalikan list<PandaPanel\Support\Breadcrumb>. Trail default adalah dashboard → navigation group → Page saat ini:
use PandaPanel\Support\Breadcrumb;
/**
* @return list<Breadcrumb>
*/
public function breadcrumbs(): array
{
return [
Breadcrumb::make('Dashboard')->url($this->dashboardUrl()),
Breadcrumb::make('Reports')->url('/admin/reports'),
Breadcrumb::make('Throughput')->current(),
];
}2
3
4
5
6
7
8
9
10
11
12
13
Lihat Breadcrumbs.
headerActions()
Berupa plain array dengan shape yang cocok dengan ActionDefinition di frontend, dirender di sisi kanan heading oleh panel/Page.
use PandaPanel\Actions\Enums\ActionVariant;
use PandaPanel\Pages\Settings\ProfileSettings;
/**
* @return list<array<string, mixed>>
*/
public function headerActions(): array
{
return [[
'name' => 'edit-profile',
'label' => 'Edit profile',
'icon' => 'settings',
'variant' => ActionVariant::Default->value,
'type' => 'link',
'url' => ProfileSettings::url($this->panel()),
'confirmation' => null,
]];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Gunakan 'type' => 'link' bersama url. Generic page renderer menggambar setiap entry dengan ActionButton, sedangkan button non-link mengemit event run yang tidak didengarkan oleh apa pun pada standalone Page. Page yang membutuhkan callback action harus merender component sendiri dan melakukan wiring useActions() sendiri.
filterSchema()
Satu form yang dibaca oleh seluruh widget pada Page. Default-nya null; karena itu frontend tidak merender filter bar sama sekali, bukan merender bar kosong.
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\FormSchema;
public function filterSchema(): FormSchema
{
return FormSchema::make()->schema([
Select::make('period')
->label('Period')
->options(['month' => 'This month', 'year' => 'This year'])
->default('month'),
]);
}2
3
4
5
6
7
8
9
10
11
12
State disimpan per Page di bawah panel.{panelId}.page.{slug} — yaitu filterSessionKey() — karena dua dashboard dengan filter berbeda merupakan dua pertanyaan berbeda. Lihat Widget filters.
Filter bar dirender oleh panel/Dashboard, bukan oleh panel/Page. Page yang mendeklarasikan filter sebaiknya meng-extend PandaPanel\Pages\Dashboard, menetapkan $component = 'panel/Dashboard', atau merender filter bar di custom component miliknya.
render()
Jarang perlu di-override. Method melakukan authorization, resolve filter dan widget, lalu mengembalikan Inertia response:
abort_unless(static::canAccess(), 403);
return Inertia::render(static::$component, [
'page' => $this->metadata(),
'widgets' => $widgets->definitions(),
'widgetData' => $widgets->deferred(),
'filters' => $schema === null ? null : ['form' => $schema->toArrayWithState(null, $filters->dashboard())],
...$this->props(),
]);2
3
4
5
6
7
8
9
metadata()
Prop page yang dikirim setiap screen Panel:
| Key | Type | Source |
|---|---|---|
title | string | title() |
heading | string | heading() |
subheading | string|null | $subheading |
breadcrumbs | list<array> | breadcrumbs() |
headerActions | list<array> | headerActions() |
scope | string | renderHookScope() |
cluster | array|null | ClusterNavigation::for(), null di luar cluster |
Override untuk menghitung subheading saat runtime — tidak ada accessor subheading() pada Page:
/**
* @return array<string, mixed>
*/
protected function metadata(): array
{
return [
...parent::metadata(),
'subheading' => 'Last run '.$this->lastRunAt()->diffForHumans(),
];
}2
3
4
5
6
7
8
9
10
panel() dan dashboardUrl()
panel() mengembalikan Panel yang di-resolve untuk request saat ini dan melempar PanelRegistrationException::noCurrentPanel() bila dipanggil di luar context tersebut. dashboardUrl() adalah root Panel itu dan digunakan oleh default breadcrumb trail.
Memberikan Vue component sendiri pada Page
php artisan make:panel-page AuditLog --panel=Admin --componentCommand tersebut menulis dua file:
| File | Tujuan |
|---|---|
app/Panels/Admin/Pages/AuditLog.php | class Page, dengan $component = 'Panels/Admin/Pages/AuditLog' |
resources/js/pages/Panels/Admin/Pages/AuditLog.vue | component |
$component merupakan nama Inertia component yang di-resolve terhadap resources/js/pages. Karena itu panel/Page berarti resources/js/pages/panel/Page.vue, sedangkan Panels/Admin/Pages/AuditLog berarti resources/js/pages/Panels/Admin/Pages/AuditLog.vue. Root generated tree dapat dikonfigurasi melalui panda-panel.frontend.pages_path.
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PageHeader from '@/panel/components/PageHeader.vue';
import PanelLayout from '@/panel/layouts/PanelLayout.vue';
import type { PageMetadata } from '@/panel/types/page';
defineOptions({ layout: PanelLayout });
defineProps<{
page: PageMetadata;
settings: { label: string; value: string }[];
}>();
</script>
<template>
<Head :title="page.title" />
<div class="flex flex-col gap-6">
<PageHeader :heading="page.heading" :subheading="page.subheading" />
<dl class="grid gap-2">
<div v-for="row in settings" :key="row.label" class="flex gap-2">
<dt class="text-muted-foreground">{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</div>
</template>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
defineOptions({ layout: PanelLayout }) adalah baris yang menempatkan Page di dalam shell Panel, dan ini merupakan baris yang harus Anda tambahkan sendiri: stubs/panel/page-component.stub hanya menulis <Head> dan PageHeader. Component tanpa layout akan menerima layout yang diberikan Inertia entry aplikasi kepada Page yang tidak memiliki case khusus; pada starter kit hal itu berarti signed-in application shell — HTTP 200, sidebar host, dan navigation milik Panel tidak terlihat.
Generator
php artisan make:panel-page {name} --panel=Admin [--component] [--force]| Option | Efek |
|---|---|
--panel= | Wajib. Admin dan admin berarti Panel yang sama; bentuk studly adalah bentuk canonical |
--component | Juga menulis file Vue dan mengarahkan $component ke file tersebut |
--force | Menimpa file yang sudah ada |
Tanpa --panel, command menghasilkan error dan failure exit code. Existing file dilewati dengan warning daripada ditimpa. Publish stubs/panel/page.stub ke aplikasi bila ingin mengubah hasil generator. Lihat make:panel-page.
Mendaftarkan Page
Discovery merupakan cara normal:
$panel->discoverPages(app_path('Panels/Admin/Pages'));Explicit registration juga bekerja dan digabungkan dengan discovery, sehingga class yang disebut di kedua tempat tetap hanya muncul sekali:
$panel->pages([\App\Panels\Admin\Pages\Settings::class]);Lihat Page discovery.
Gotchas
- Page yang disembunyikan tetap dapat diakses melalui URL dan tetap melakukan pemeriksaan akses.
$shouldRegisterNavigation = falsehanya menghapus sidebar entry.render()selalu dimulai denganabort_unless(static::canAccess(), 403), dan itulah kontrol sebenarnya. - Page props dapat menimpa framework props.
props()di-spread paling akhir, sehingga key bernamapage,widgets,widgetData, ataufiltersakan mengganti nilai framework. Beri nama Page prop berdasarkan data yang benar-benar dibawanya. - Slug Page tidak boleh collision dengan slug Resource pada Panel yang sama.
PageRegistrymelemparPanelRegistrationException::slugCollidesWithResource()saat registration daripada membiarkan dua route berebut path yang sama. - Root dashboard bukan Page route. Root dashboard merespons pada path Panel dan didaftarkan secara terpisah, sehingga tidak memiliki route
pages.*. Extra dashboard yang dideklarasikan melaluidashboards()mendapatkan Page route. Lihat Dashboards. filterstetap dikirim walaupun component mengabaikannya.panel/Pagetidak mendeklarasikan propfilters; hanyapanel/Dashboardyang merender bar.- Header action pada standalone Page harus berupa link. Generic renderer tidak menangani event
runmilik callback action. panel()melempar exception di luar request Panel. Page yang diinstansiasi pada unit test membutuhkanapp(PanelManager::class)->setCurrentPanel(panel('admin'))terlebih dahulu, seperti yang dilakukan test framework.- Generated Vue component tidak mendeklarasikan layout. Published component di bawah
resources/js/pages/panelsemuanya mendeklarasikan layout — dan ada test yang menjaganya — tetapi stub--componenttidak. TambahkandefineOptions({ layout: PanelLayout })pada component yang dibuat generator. make:panel-pagemenghasilkan exit code non-zero ketika semua file yang akan dibuat sudah ada.report()mengembalikan failure ketika tidak ada file dibuat dan ada file yang dilewati, sehingga kebutuhan--forceterlihat jelas di CI dan tidak menjadi silent no-op.