Custom Page Components
Setiap screen panel adalah Inertia response yang menyebut nama sebuah Vue component. Halaman ini membahas cara mengganti component tersebut dengan component milik Anda sendiri — baik untuk standalone page yang membutuhkan tampilan yang tidak dapat digambar generic renderer, maupun untuk resource page yang memerlukan layout berbeda dari yang disediakan package.
Sebuah Page tidak selalu membutuhkan file Vue. Page::$component secara default adalah panel/Page, yaitu generic renderer. Karena itu standalone page paling sederhana cukup berupa PHP class dengan title. Gunakan custom component ketika content page memang tidak memiliki shape yang dapat direpresentasikan framework.
Contoh minimal yang berfungsi
php artisan make:panel-page Reports --panel=Admin --componentCommand menghasilkan dua file. Class-nya:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Pages;
use PandaPanel\Pages\Page;
final class Reports extends Page
{
protected static ?string $navigationIcon = 'file-text';
protected static int $navigationSort = 0;
protected static string $component = 'Panels/Admin/Pages/Reports';
/**
* @return array<string, mixed>
*/
public function props(): array
{
return [
'totals' => ['orders' => 128, 'revenue' => '4,201.00'],
];
}
}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
Dan component pada resources/js/pages/Panels/Admin/Pages/Reports.vue:
<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;
totals: { orders: number; revenue: 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-4 sm:grid-cols-2">
<div class="rounded-lg border p-4">
<dt class="text-sm text-muted-foreground">Orders</dt>
<dd class="text-2xl tabular-nums">{{ totals.orders }}</dd>
</div>
<div class="rounded-lg border p-4">
<dt class="text-sm text-muted-foreground">Revenue</dt>
<dd class="text-2xl tabular-nums">{{ totals.revenue }}</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
29
30
31
32
npm run build # atau: npm run dev$component adalah nama Inertia component
protected static string $component = 'panel/Page';Value ini langsung diberikan ke Inertia::render(), sehingga di-resolve oleh Inertia resolver milik application — biasanya glob terhadap resources/js/pages/**. Ini bukan registry key dan tidak di-resolve melalui import.meta.glob seperti custom column atau custom widget:
| Value | File |
|---|---|
panel/Page | resources/js/pages/panel/Page.vue (bawaan package) |
panel/Dashboard | resources/js/pages/panel/Dashboard.vue (bawaan package) |
Panels/Admin/Pages/Reports | resources/js/pages/Panels/Admin/Pages/Reports.vue (milik Anda) |
Kedua directory berada di bawah resources/js/pages karena itulah root yang dilihat resolver. Panels/ menggunakan huruf kapital sedangkan panel/ tidak; convention ini membuat screen framework dan application mudah dibedakan secara visual.
Jika nama component tidak dapat di-resolve, Inertia gagal di browser dan menampilkan nama component pada error. Berbeda dengan registry miss yang dapat turun ke fallback, sebuah page tidak memiliki fallback lain untuk dirender.
Generic renderer
Jika $component tidak diubah, framework menggunakan resources/js/pages/panel/Page.vue, yang menggambar:
<Head :title="page.title" />;PageHeaderdengan heading, subheading, dan header actions;WidgetGridjika page mendeklarasikan widgets;- sebuah slot yang secara default tidak diisi — sehingga page tanpa widgets hanya menampilkan header.
Props-nya:
defineProps<{
page: PageMetadata;
widgets: WidgetDefinition[];
widgetData?: WidgetData | null;
}>();2
3
4
5
Itulah alasan page tidak harus memiliki Vue file sendiri: settings screen yang hanya berisi widgets sudah dapat digambar generic renderer.
Prop yang diterima setiap page component
Page::render() mengirim empat framework props, kemudian melakukan spread terhadap props():
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
| Prop | Type | Selalu tersedia |
|---|---|---|
page | PageMetadata | ya |
widgets | WidgetDefinition[] | ya, array kosong jika tidak ada widget |
widgetData | WidgetData | null | deferred — tidak ada pada initial response |
filters | { form: FormDefinition } | null | ya, null kecuali filterSchema() mengembalikan schema |
value dari props() | milik Anda | — |
page sebaiknya dideklarasikan pada setiap page component karena layout juga membacanya:
export interface PageMetadata {
title: string;
heading: string;
subheading: string | null;
breadcrumbs: PanelBreadcrumbItem[];
headerActions: unknown[];
scope: string | null;
subNavigation: PageSubNavigation;
cluster: ClusterNavigation | null;
}2
3
4
5
6
7
8
9
10
headerActions menggunakan unknown[], bukan typed union. Lakukan cast di tempat pemakaian, sama seperti shipped renderer:
import type { ActionDefinition } from '@/panel/types/action';
const headerActions = props.page.headerActions as ActionDefinition[];2
3
widgetData bersifat deferred, sehingga prop harus optional:
withDefaults(
defineProps<{
page: PageMetadata;
widgets: WidgetDefinition[];
widgetData?: WidgetData | null;
}>(),
{ widgetData: null },
);2
3
4
5
6
7
8
Mendeklarasikan layout
Setiap page component harus mendeklarasikan layout-nya sendiri. Ini adalah baris paling penting pada file custom page:
import PanelLayout from '@/panel/layouts/PanelLayout.vue';
defineOptions({ layout: PanelLayout });2
3
Tanpa baris tersebut, page akan memakai layout fallback dari resolver application. Pada Laravel Vue starter kit, fallback biasanya shell authenticated application. Akibatnya panel dapat tampil di dalam sidebar milik host application sementara navigation panel hilang — tetap HTTP 200 dan tanpa log error.
Stub generator --component saat ini tidak menyertakan baris tersebut. Tambahkan secara manual pada component hasil generator.
PanelLayout memilih sidebar shell atau header shell berdasarkan konfigurasi panel dan me-resolve breadcrumbs dari page.breadcrumbs, sehingga page tidak perlu melakukan wiring kedua concern tersebut. Layout hanya menerima satu optional prop untuk page yang benar-benar membangun breadcrumb di client:
defineProps<{ breadcrumbs?: PanelBreadcrumbItem[] }>();Generator
php artisan make:panel-page Reports --panel=Admin
php artisan make:panel-page Reports --panel=Admin --component
php artisan make:panel-page Reports --panel=Admin --component --force2
3
| Option | Efek |
|---|---|
--panel= | wajib; panel tempat page berada. Admin dan admin mengarah ke panel yang sama |
--component | juga membuat Vue file dan mengatur $component menjadi Panels/{Panel}/Pages/{Class} |
--force | overwrite file yang sudah ada |
Tanpa --component, $component ditulis sebagai panel/Page dan tidak ada Vue file yang dibuat.
Vue file ditulis ke FrontendPaths::pages("{$panel}/Pages/{$class}.vue") — secara default resources/js/pages/Panels/Admin/Pages/Reports.vue, atau mengikuti panda-panel.frontend.pages_path jika konfigurasi diubah.
Generator menolak overwrite file existing dan melaporkannya sebagai skipped. Jika semua file yang seharusnya dibuat sudah ada, command keluar dengan non-zero exit code. Dengan begitu CI dapat mendeteksi bahwa --force diperlukan, bukan diam-diam melakukan no-op.
Resource pages
Resource page menggunakan mekanisme $component yang sama dan masing-masing memiliki shipped default:
| Page class | Default $component | Shipped file |
|---|---|---|
PandaPanel\Resources\Pages\ListRecords | panel/resources/Index | pages/panel/resources/Index.vue |
PandaPanel\Resources\Pages\CreateRecord | panel/resources/Create | pages/panel/resources/Create.vue |
PandaPanel\Resources\Pages\ViewRecord | panel/resources/View | pages/panel/resources/View.vue |
PandaPanel\Resources\Pages\EditRecord | panel/resources/Edit | pages/panel/resources/Edit.vue |
PandaPanel\Resources\Pages\ManageRelatedRecords | panel/resources/ManageRelated | pages/panel/resources/ManageRelated.vue |
namespace App\Panels\Admin\Resources\Users\Pages;
use PandaPanel\Resources\Pages\ListRecords;
final class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected static string $component = 'Panels/Admin/Pages/UsersBoard';
}2
3
4
5
6
7
8
9
10
Mengganti component resource page berarti Anda mengambil alih seluruh prop dan wiring page tersebut. panel/resources/Index sendiri memiliki tiga belas props — page, resource, table, state, rows, pagination, summaries, groupSummaries, actionEndpoints, tabs, headerWidgets, footerWidgets, widgetData — dan menghubungkannya ke table components serta useResource.
Baca shipped file terlebih dahulu sebelum menggantinya. Dalam banyak kasus, custom column atau render hook adalah perubahan yang lebih kecil dan lebih aman.
Menggunakan kembali component bawaan panel
Semua file di bawah resources/js/panel dapat di-import. Inilah yang membuat custom page tetap murah untuk dibangun:
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import EmptyState from '@/panel/components/EmptyState.vue';
import PageHeader from '@/panel/components/PageHeader.vue';
import PanelRenderHook from '@/panel/components/PanelRenderHook.vue';
import { usePanelStyling } from '@/panel/composables/usePanelStyling';
import PanelLayout from '@/panel/layouts/PanelLayout.vue';
import type { PageMetadata } from '@/panel/types/page';
import type { WidgetData, WidgetDefinition } from '@/panel/types/widget';
import WidgetGrid from '@/panel/widgets/WidgetGrid.vue';
defineOptions({ layout: PanelLayout });
withDefaults(
defineProps<{
page: PageMetadata;
widgets: WidgetDefinition[];
widgetData?: WidgetData | null;
rows: Array<{ id: number; label: string }>;
}>(),
{ widgetData: null },
);
const { hook } = usePanelStyling();
</script>
<template>
<Head :title="page.title" />
<div class="flex flex-col gap-6">
<PageHeader :heading="page.heading" :subheading="page.subheading" />
<PanelRenderHook name="page.start" />
<WidgetGrid
v-if="widgets.length > 0"
:widgets="widgets"
:widget-data="widgetData"
/>
<EmptyState v-if="rows.length === 0" heading="Nothing yet" />
<!-- Block milik Anda tetap dapat dijangkau cssHooks milik panel. -->
<section v-else class="rounded-lg border p-4" :class="hook('widget')">
<p v-for="row in rows" :key="row.id" class="text-sm">
{{ row.label }}
</p>
</section>
</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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
PageHeader sudah menerapkan hook('page-header'), dan layout menerapkan hook('page') pada wrapper content. Memanggil hook() pada block buatan Anda sendiri memungkinkan cssHooks() milik panel menjangkau content tersebut. Lihat CSS Hooks.
Gotchas
- Generated page component tidak mendeklarasikan layout. Shipped pages di
resources/js/pages/panelsemuanya mendeklarasikan layout dan test memastikannya, tetapi stub--componentbelum. TambahkandefineOptions({ layout: PanelLayout }). props()di-spread paling akhir. Jikaprops()mengembalikan keypage,widgets,widgetData, ataufilters, Anda akan menimpa prop framework. Gunakan nama yang menggambarkan data milik page.filterstetap dikirim walaupun component tidak menggunakannya. Hanyapanel/Dashboardyang merender filter bar;panel/Pagetidak mendeklarasikan prop tersebut.$componentbukan registry key.import.meta.globtidak digunakan. Nama tersebut melewati Inertia page resolver milik application, sehingga nama invalid menjadi runtime error, bukan fallback.- Header actions pada generic renderer harus berupa link.
panel/Pagetidak menangani eventrununtuk callback action. - Page dengan custom component tetap menjalankan
canAccess().render()dimulai denganabort_unless(static::canAccess(), 403); Vue component tidak mengubah authorization. panel()melempar exception di luar panel request. Unit test yang menginstansiasi page harus mengatur current panel terlebih dahulu.pages_pathhanya memindahkan lokasi generator menulis, bukan lokasi yang dicari Inertia. Inertia tetap me-resolve dariresources/js/pages; config hanya mengatur subdirectory tujuan generator.