Custom Widgets
PandaPanel\Widgets\CustomWidget adalah widget yang body-nya Anda gambar sendiri melalui Vue single-file component. Gunakan ketika data yang ingin ditampilkan bukan sekadar deretan angka, chart, atau table — misalnya status board, progress ring, map, feed, atau card informasi sistem.
Halaman ini membahas sisi frontend: prop apa yang diterima component, di mana file harus diletakkan, dan bagian apa saja yang sudah digambar oleh shell. Sisi PHP — filters, polling, lazy loading, authorization — dibahas di Custom Vue Widgets.
Contoh minimal yang berfungsi
php artisan make:panel-widget SystemInfo --panel=Admin --type=customCommand menghasilkan dua file. Class:
<?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 int $sort = 40;
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
27
28
Dan 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
npm run build # atau: npm run devClass
CustomWidget extends PandaPanel\Widgets\Widget dan menambahkan satu property serta satu method utama untuk component.
| Member | Signature | Default |
|---|---|---|
$component | protected static string $component | '' |
type() | public static function type(): WidgetType | WidgetType::Custom |
component() | public static function component(): string | mengembalikan $component |
data() | abstract public function data(): array | tetap abstract — wajib Anda implementasikan |
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';make:panel-widget --type=custom otomatis menulis value sebagai Panels/{Panel}/Widgets/{Class} dan membuat .vue file yang sesuai. Untuk custom widget, component bukan optional; tanpa component widget hanya akan menampilkan fallback.
component()
public static function component(): stringMethod mengembalikan $component, dan melempar RuntimeException jika property masih menggunakan empty default:
The custom widget [App\Panels\Admin\Widgets\ServerHealth] must declare a $component.Ini adalah satu-satunya bagian pada flow ini yang throw alih-alih degrade. Error tersebut adalah developer error yang dideteksi saat serialization sebelum data mencapai browser. Override method hanya jika nama component memang perlu dihitung; karena method tetap static, nilainya tidak dapat bergantung pada request.
data()
/** @return array<string, mixed> */
abstract public function data(): array;2
Setiap key menjadi prop langsung pada component karena CustomWidget.vue melakukan v-bind terhadap payload:
<component :is="resolved" v-if="resolved" v-bind="data" />Jadi ['laravel' => …, 'php' => …] menghasilkan prop laravel dan php, bukan satu prop bernama data. Deklarasikan masing-masing melalui defineProps. Key yang tidak dideklarasikan component akan menjadi fall-through attribute pada root element.
Gunakan hanya scalar, array, dan null. Model harus Anda serialisasi sendiri:
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()
Base widget definition ditambah satu key component:
[
'id' => 'system-info',
'type' => 'custom',
'sort' => 40,
'columnSpan' => ['default' => 1, 'md' => 1, 'lg' => 1, 'xl' => 1],
'lazy' => false,
'heading' => null,
'description' => null,
'polling' => null,
'filters' => null,
'data' => ['laravel' => '…', 'php' => '…'],
'component' => 'Panels/Admin/Widgets/SystemInfo',
]2
3
4
5
6
7
8
9
10
11
12
13
Lazy widget diserialisasi dengan data => null; payload aktual datang terpisah sebagai deferred prop keyed berdasarkan widget id.
Inherited statics yang penting
use PandaPanel\Widgets\CustomWidget;
final class ServerHealth extends CustomWidget
{
protected static string $component = 'Panels/Admin/Widgets/ServerHealth';
protected static int $sort = 10;
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2, 'lg' => 1, 'xl' => 2];
protected static ?string $heading = 'Server health';
protected static ?string $description = 'Updated every fifteen seconds.';
protected static bool $lazy = true;
protected static ?int $pollingInterval = 15;
public static function canView(): bool
{
return auth()->user()?->can('viewInfrastructure') ?? false;
}
public function data(): array
{
return ['load' => sys_getloadavg()[0] ?? 0.0];
}
}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
| Member | Type | Default |
|---|---|---|
$sort | int | 0 |
$columnSpan | int|string|array | 1 |
$lazy | bool | false |
$heading | ?string | null |
$description | ?string | null |
$pollingInterval | ?int detik | null |
canView() | static canView(): bool | true |
Yang sudah digambar shell
Component Anda hanya merender body widget. WidgetShell.vue membungkus setiap widget, apa pun tipenya, dan sudah menyediakan:
- heading dan description;
- filter form, inline atau di dalam dialog;
- polling timer, yang melakukan reload terhadap widget props milik page, bukan memanggil satu endpoint khusus widget;
- grid cell dan column span melalui
WidgetGrid.vue; - CSS hook class
panel-widget.
Karena itu jangan menggambar heading kedua di dalam component jika $heading sudah diatur pada class. Sesuaikan ukuran terhadap cell yang diberikan, bukan viewport. h-full pada root component biasanya tepat karena tinggi grid row mengikuti widget tertinggi pada row tersebut.
Ketika lazy widget masih menunggu payload, shell menampilkan skeleton dan component belum di-mount sama sekali. Component baru di-mount setelah data tersedia, sehingga props tidak pernah perlu berada pada state undefined karena lazy request.
Lokasi component
resources/js/pages/Panels/**/Widgets/*.vueimport {
resolveWidgetComponent,
hasWidgetComponent,
} from '@/panel/widgets/registry';
hasWidgetComponent('Panels/Admin/Widgets/SystemInfo'); // boolean
resolveWidgetComponent('Panels/Admin/Widgets/SystemInfo'); // loader atau null2
3
4
5
6
7
| Function | Signature |
|---|---|
hasWidgetComponent | (name: string) => boolean |
resolveWidgetComponent | (name: string) => (() => Promise<{ default: Component }>) | null |
Registry key adalah path di bawah pages/ tanpa extension, sehingga $component ditulis sebagai Panels/Admin/Widgets/SystemInfo. Pattern berakhir di *.vue, jadi Widgets/Charts/Revenue.vue tidak diregistrasikan.
Directory yang ditulis generator berasal dari PandaPanel\Support\FrontendPaths::pages(), yang diatur oleh panda-panel.frontend.pages_path. Mengubah config tersebut memindahkan output generator, sedangkan glob adalah literal string di resources/js/panel/widgets/registry.ts dan harus ikut disesuaikan.
Ketika nama tidak dapat di-resolve
WidgetFallback.vue digunakan: dashed box dengan alert icon dan text This widget is unavailable. Fallback dibuat netral karena typo pada satu component name tidak boleh menjatuhkan seluruh dashboard.
Pada development, registry menulis warning satu kali per nama:
[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
4
Production tidak menulis warning. Ini adalah build problem dan console warning di live panel tidak membantu operator. Dari layar, tiga penyebab berikut terlihat sama:
- typo pada
$componentatau filename; - file berada di luar
resources/js/pages/Panels/**/Widgets/; - build belum dijalankan ulang setelah file dibuat.
Server tidak dapat memverifikasi hal tersebut karena server tidak melihat isi bundle. Ia hanya menyerialisasi nama component, lalu frontend yang menentukan apakah component tersedia.
Filters dan polling dari sisi component
Keduanya merupakan concern shell, bukan custom component. Widget yang memiliki filter schema mendapatkan controls dari WidgetFilters.vue, dan value filter ditulis ke query string. Dengan demikian dashboard yang terfilter tetap merupakan URL yang dapat dibagikan dan browser back button tetap memiliki arti yang benar. Server menormalisasi input melalui schema yang mendeklarasikannya lalu memanggil data() kembali.
Polling merupakan partial reload terhadap widget props pada page:
router.reload({ only: ['widgets', 'widgetData'] });Component menerima props baru lalu re-render. Component tidak perlu melakukan fetch sendiri, dan sebaiknya tidak membuat endpoint paralel: endpoint khusus satu widget tetap harus me-resolve page authorization, filters, dan context agar jawabannya benar.
Gotchas
- Key dari
data()menjadi props langsung, bukan object payload. Component dengandefineProps<{ data: … }>()tidak akan menerima isi array tersebut. - Prop yang tidak dideklarasikan menjadi HTML attribute. Vue meneruskan undeclared props ke root element, sehingga typo dapat terlihat sebagai stray attribute alih-alih error.
$componentbersifat static. Jika widget harus memilih component berbeda per user, buat dua widget dengancanView()berbeda.- File baru membutuhkan rebuild.
import.meta.globdievaluasi saat build. Ini adalah penyebab paling umum widget yang sudah ada tetapi masih menampilkan fallback. - Glob menggunakan relative path, bukan alias. Pada Vite dev server, aliased glob dapat menghasilkan kosong sementara production build bekerja, sehingga seluruh custom widget gagal di development tetapi berhasil setelah build.
make:panel-widget --type=customselalu membuat.vuefile. Tiga tipe widget lain tidak membutuhkan file custom karena renderer sudah disediakan package.- Semua import tambahan harus sudah tersedia dalam frontend dependencies application. Package tidak otomatis memasang charting/UI library lain di luar dependency yang memang dipakai panel.
- Authorization widget adalah
canView(), sedangkan page tempat widget berada memiliki authorization sendiri. Menggambar sesuatu di component bukan permission check.