Custom Vue Widget
Custom widget adalah widget yang bagian body-nya Anda gambar sendiri menggunakan Vue single-file component. Gunakan jenis widget ini ketika bentuk informasi yang ingin ditampilkan bukan sekadar deretan angka, chart yang dapat diekspresikan oleh closed set ChartOptions, atau tabel — misalnya status board, peta, progress ring, atau feed.
Class PHP tetap menjadi pemilik data. Nama component berasal dari class, tidak pernah dari request, dan frontend me-resolve-nya melalui glob pada saat build. Artinya nama component yang tidak ikut dikompilasi tidak dapat dijangkau, apa pun input yang diterima aplikasi.
Contoh minimal yang dapat langsung digunakan
php artisan make:panel-widget SystemInfo --panel=Admin --type=customCommand tersebut menulis dua file. Pertama, class PHP:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use Illuminate\Foundation\Application;
use PandaPanel\Widgets\CustomWidget;
final class SystemInfo extends CustomWidget
{
protected static string $component = 'Panels/Admin/Widgets/SystemInfo';
/**
* @return array<string, mixed>
*/
public function data(): array
{
return [
'laravel' => Application::VERSION,
'php' => PHP_VERSION,
'environment' => app()->environment(),
'debug' => (bool) config('app.debug'),
];
}
}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
Lalu component pada resources/js/pages/Panels/Admin/Widgets/SystemInfo.vue:
<script setup lang="ts">
defineProps<{
laravel: string;
php: string;
environment: string;
debug: boolean;
}>();
</script>
<template>
<div class="flex h-full flex-col gap-3 rounded-lg border p-4">
<h3 class="text-sm font-medium">System</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">Laravel</dt>
<dd class="tabular-nums">{{ laravel }}</dd>
<dt class="text-muted-foreground">PHP</dt>
<dd class="tabular-nums">{{ php }}</dd>
<dt class="text-muted-foreground">Environment</dt>
<dd>{{ environment }}</dd>
<dt class="text-muted-foreground">Debug</dt>
<dd>{{ debug ? 'On' : 'Off' }}</dd>
</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
Build ulang frontend dengan npm run dev atau npm run build, lalu widget akan muncul pada dashboard.
Class
PandaPanel\Widgets\CustomWidget extends PandaPanel\Widgets\Widget.
| Member | Signature | Default |
|---|---|---|
$component | protected static string | '' |
type() | public static function type(): WidgetType | WidgetType::Custom |
component() | public static function component(): string | $component |
data() | abstract public function data(): array | diwarisi dan tetap abstract |
toDefinition() | public function toDefinition(): array | base definition ditambah component |
$component
Path di bawah resources/js/pages/ tanpa extension .vue:
protected static string $component = 'Panels/Admin/Widgets/ServerHealth';Generator menuliskannya dalam format Panels/{Panel}/Widgets/{Class}.
component()
public static function component(): stringMethod ini mengembalikan $component dan melempar RuntimeException jika property tersebut masih menggunakan default kosong:
The custom widget [App\Panels\Admin\Widgets\ServerHealth] must declare a $component.Lakukan override hanya jika nama component memang harus dihitung secara dinamis. Karena method ini static, hasilnya tetap tidak dapat bergantung pada request.
data()
/** @return array<string, mixed> */
abstract public function data(): array2
Gunakan scalar, array, dan null. Setiap key akan menjadi prop pada component karena renderer melakukan binding payload dengan v-bind:
<component :is="resolved" v-bind="data" />Jadi data() yang mengembalikan ['laravel' => ..., 'php' => ...] akan memberikan prop laravel dan php, bukan satu prop bernama data. Deklarasikan prop tersebut dengan defineProps agar kontrak data tetap eksplisit.
Serialize model sendiri — kirim array, bukan Eloquent object:
use App\Models\Order;
public function data(): array
{
return [
'orders' => Order::query()
->latest()
->limit(5)
->get(['id', 'reference', 'total'])
->map(static fn (Order $order): array => [
'id' => $order->id,
'reference' => $order->reference,
'total' => (string) $order->total,
])
->all(),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
toDefinition()
Method ini menambahkan satu key ke base definition:
[
'id' => 'system-info',
'type' => 'custom',
'component' => 'Panels/Admin/Widgets/SystemInfo',
// ...sort, columnSpan, lazy, heading, description, polling, filters, data
]2
3
4
5
6
Lokasi component yang benar
Frontend me-resolve nama component melalui import.meta.glob pada saat build terhadap path berikut:
resources/js/pages/Panels/**/Widgets/*.vueRegistry key adalah path di bawah pages/ tanpa extension. Karena itu $component ditulis sebagai Panels/Admin/Widgets/SystemInfo. Ada dua konsekuensi penting:
- Component yang berada di lokasi lain — misalnya
resources/js/components/atau nested directoryWidgets/Parts/— tidak masuk glob dan tidak dapat di-resolve. - Glob tersebut merupakan allowlist pada saat build. Nama yang tidak ikut dikompilasi tidak dapat mengakses component apa pun, meskipun nama tersebut datang dari request.
Directory ini sama dengan lokasi yang digunakan PandaPanel\Support\FrontendPaths::pages(), yang dikonfigurasi melalui panda-panel.frontend.pages_path:
'frontend' => [
'panel_path' => 'js/panel',
'pages_path' => 'js/pages/Panels',
],2
3
4
Mengubah pages_path akan memindahkan lokasi output generator, tetapi glob di resources/js/panel/widgets/registry.ts adalah literal string yang sudah dikompilasi ke bundle. Jika salah satunya dipindahkan, yang lain juga harus disesuaikan. Lihat Component registries.
Ketika nama component tidak dapat di-resolve
Renderer menampilkan fallback netral, bukan melempar exception: sebuah kotak bergaris putus-putus dengan teks "This widget is unavailable." Satu kesalahan penulisan nama component tidak akan menjatuhkan seluruh dashboard.
Pada environment development, warning berikut juga ditulis satu kali per nama ke console:
[panel] The widget component [Panels/Admin/Widgets/Typo] is not in the build-time
registry, so a fallback is drawn instead. It has to live under
resources/js/pages/Panels/{Panel}/Widgets/ — check the path and the spelling, then rebuild.2
3
Pada production tidak ada warning console, karena ini adalah masalah build dan pesan console pada panel production tidak membantu pengguna. Dari layar, tiga penyebab berikut terlihat sama:
- typo pada
$componentatau nama file; - file berada di luar
resources/js/pages/Panels/**/Widgets/; - frontend belum di-build ulang setelah file ditambahkan.
Backend tidak memeriksa hal-hal tersebut karena backend tidak dapat mengetahui isi bundle. Backend hanya men-serialize nama yang diberikan dan membiarkan frontend melakukan resolution; package memiliki test yang memastikan perilaku ini.
Apa yang sudah digambar oleh shell
Component Anda hanya merender bagian body. WidgetShell.vue membungkus setiap widget dan sudah menyediakan:
$headingdan$description;- form filter, baik inline maupun melalui dialog;
- polling timer;
- grid cell beserta column span;
- CSS hook class
panel-widget.
Karena itu jangan menggambar heading lagi di dalam component jika class sudah menetapkan $heading. Sesuaikan ukuran component terhadap cell yang diberikan, bukan terhadap viewport.
Selama payload custom widget lazy masih diproses, shell menampilkan skeleton tiga baris dan component Anda belum di-mount sama sekali. Component baru di-mount setelah data tersedia, sehingga props tidak pernah berada dalam kondisi undefined karena proses lazy loading.
Filter dan polling
Custom widget menggunakan mekanisme yang sama dengan type widget lainnya:
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Widgets\CustomWidget;
final class ServerHealth extends CustomWidget
{
protected static string $component = 'Panels/Admin/Widgets/ServerHealth';
protected static ?int $pollingInterval = 15;
protected static bool $lazy = true;
public function filterSchema(): FormSchema
{
return FormSchema::make()->schema([
Select::make('region')
->options(['eu' => 'Europe', 'us' => 'United States'])
->default('eu'),
]);
}
public function data(): array
{
return ['region' => (string) $this->filter('region', 'eu')];
}
}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
Polling merupakan partial reload terhadap widget props milik page. Karena itu component akan menerima props baru dan melakukan re-render; component tidak perlu melakukan fetch sendiri. Lihat Polling dan Filter.
Catatan
- Nama component ditentukan pada saat build, sedangkan payload adalah data. Custom widget tetap bukan tempat untuk mengirim executable behaviour dari server — handler, callback, atau class name tidak dikirim ke browser.
$componentbersifat static. Jika satu widget harus memilih salah satu dari dua component berdasarkan user, buat dua widget dengan aturancanView()yang berbeda.- Generator menolak menimpa file yang sudah ada. Gunakan
--forcejika memang ingin melakukan overwrite. make:panel-widget --type=customselalu membuat file.vuejuga, karena custom widget tanpa component hanya akan menampilkan fallback.- Semua dependency yang di-import oleh component harus sudah tersedia pada frontend dependency aplikasi. Package tidak menginstal charting library atau UI library tambahan selain yang memang digunakan panel.