Custom Widget
Custom Widget adalah widget dashboard yang isi utamanya Anda gambar sendiri menggunakan Vue single-file component. Halaman ini membangun board stok rendah untuk product dari Product Resource: daftar product yang berada di bawah reorder point, memiliki filter sendiri, dimuat secara lazy, dan di-refresh dengan polling. Gunakan PandaPanel\Widgets\CustomWidget ketika data yang ingin ditampilkan bukan sekadar deretan angka, chart, atau table. Jika bentuknya cocok dengan salah satu dari tiga jenis widget tersebut, gunakan widget bawaan terlebih dahulu.
Contoh minimal yang berfungsi
php artisan make:panel-widget LowStock --panel=Admin --type=customGenerator membuat dua file. Pertama app/Panels/Admin/Widgets/LowStock.php:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use App\Models\Product;
use PandaPanel\Widgets\CustomWidget;
final class LowStock extends CustomWidget
{
protected static int $sort = 0;
protected static string $component = 'Panels/Admin/Widgets/LowStock';
/**
* @return array<string, mixed>
*/
public function data(): array
{
return [
'products' => Product::query()
->where('stock', '<', 10)
->orderBy('stock')
->limit(5)
->get(['id', 'name', 'sku', 'stock'])
->map(static fn (Product $product): array => [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'stock' => $product->stock,
])
->all(),
];
}
}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
Lalu resources/js/pages/Panels/Admin/Widgets/LowStock.vue:
<script setup lang="ts">
defineProps<{
products: Array<{ id: number; name: string; sku: string; stock: number }>;
}>();
</script>
<template>
<div class="flex h-full flex-col gap-3 rounded-lg border p-4">
<h3 class="text-sm font-medium">Low stock</h3>
<ul v-if="products.length" class="flex flex-col gap-2 text-sm">
<li
v-for="product in products"
:key="product.id"
class="flex items-baseline justify-between gap-3"
>
<span class="truncate">{{ product.name }}</span>
<span class="tabular-nums text-muted-foreground">
{{ product.stock }}
</span>
</li>
</ul>
<p v-else class="text-sm text-muted-foreground">
Everything is above its reorder point.
</p>
</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
npm run build # or: npm run devAdmin Panel sudah memanggil discoverWidgets(app_path('Panels/Admin/Widgets')), sehingga widget langsung muncul di /admin tanpa registration tambahan.
Generator
php artisan make:panel-widget
{name} # the widget class name
--panel= # required
--type=stats # stats, table, chart, or custom
--force2
3
4
5
--type | Base class | Yang Anda implementasikan | File tambahan |
|---|---|---|---|
stats | PandaPanel\Widgets\StatsWidget | stats(): array berisi Stat | — |
table | PandaPanel\Widgets\TableWidget | table(TableSchema $table) dan query(): Builder | — |
chart | PandaPanel\Widgets\ChartWidget | labels(): array dan series(): array | — |
custom | PandaPanel\Widgets\CustomWidget | data(): array | Vue component .vue |
--type=custom adalah satu-satunya jenis yang membuat file kedua. Custom widget tanpa component hanya dapat menampilkan fallback. Nama component yang dibuat generator adalah Panels/{Panel}/Widgets/{Class}, dan file ditempatkan di bawah panda-panel.frontend.pages_path, yang secara default menunjuk ke resources/js/pages/Panels.
--type yang tidak dikenal menghasilkan error yang menyebut empat pilihan valid. Framework tidak diam-diam fallback ke stats.
Class
CustomWidget meng-extend PandaPanel\Widgets\Widget dan menambahkan satu property serta satu method khusus.
| Member | Signature | Default |
|---|---|---|
$component | protected static string $component | '' |
type() | static type(): WidgetType | WidgetType::Custom |
component() | static component(): string | mengembalikan $component |
data() | abstract data(): array | tetap abstract — wajib Anda implementasikan |
toDefinition() | toDefinition(): array | base definition ditambah component |
component() melempar RuntimeException ketika $component masih kosong:
The custom widget [App\Panels\Admin\Widgets\LowStock] must declare a $component.Ini satu-satunya titik pada alur ini yang melempar exception alih-alih melakukan graceful fallback. Kondisi tersebut merupakan developer error dan dideteksi saat serialization, sebelum data apa pun mencapai browser.
Semua kemampuan Widget tetap tersedia
Semua property dan method berikut diwarisi dari Widget dan berlaku untuk seluruh jenis widget:
protected static int $sort = 0;
protected static int|string|array $columnSpan = 1;
protected static bool $lazy = false;
protected static ?string $heading = null;
protected static ?string $description = null;
protected static ?int $pollingInterval = null;
public static function id(): string; // kebab of the class basename
public static function canView(): bool; // true
public static function columnSpan(): array; // normalized to default/md/lg/xl
public function filterSchema(): ?FormSchema; // null
public static function filtersInModal(): bool; // false2
3
4
5
6
7
8
9
10
11
12
Implementasi widget lengkap
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use App\Models\Product;
use App\Panels\Admin\Resources\Products\ProductResource;
use Illuminate\Support\Facades\Auth;
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Widgets\CustomWidget;
/**
* Products under their reorder point, worst first.
*/
final class LowStock extends CustomWidget
{
protected static int $sort = 20;
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2, 'lg' => 2, 'xl' => 2];
protected static ?string $heading = 'Low stock';
protected static ?string $description = 'Products at or below the threshold.';
protected static string $component = 'Panels/Admin/Widgets/LowStock';
/**
* Deferred, so a scan of the products table does not hold up the first
* paint of the whole dashboard.
*/
protected static bool $lazy = true;
/**
* Stock moves when an order is placed, which is often enough to be worth
* watching. Polling is a request every interval for every open tab, so
* it is opt-in per widget rather than a setting somebody turns on once.
*/
protected static ?int $pollingInterval = 120;
/**
* Hidden entirely — never drawn, and `data()` never runs — for anybody
* the products policy will not show a list to.
*/
public static function canView(): bool
{
return Auth::check() && ProductResource::canViewAny();
}
/**
* A threshold the reader chooses. The schema is the whitelist: a key it
* never declared is not in `filter()`, whatever the query string said.
*/
public function filterSchema(): FormSchema
{
return FormSchema::make()->schema([
Select::make('threshold')
->label('Threshold')
->options([
'5' => 'Under 5',
'10' => 'Under 10',
'25' => 'Under 25',
])
->default('10'),
]);
}
/**
* Every key here becomes a prop on the component.
*
* @return array<string, mixed>
*/
public function data(): array
{
$threshold = max(1, min(100, (int) $this->filter('threshold', 10)));
$products = Product::query()
->where('stock', '<', $threshold)
->orderBy('stock')
->limit(8)
->get(['id', 'name', 'sku', 'stock']);
return [
'threshold' => $threshold,
// A URL the server produced, so the destination authorizes for
// itself when it is followed.
'indexUrl' => ProductResource::url(),
'products' => $products
->map(static fn (Product $product): array => [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'stock' => $product->stock,
'url' => ProductResource::url('edit', $product),
])
->all(),
];
}
}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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
Setiap key dari data() menjadi prop
CustomWidget.vue mengikat payload dengan v-bind:
<component :is="resolved" v-if="resolved" v-bind="data" />Artinya ['threshold' => …, 'products' => …] menghasilkan prop threshold dan products, bukan satu prop bernama data. Deklarasikan keduanya di defineProps. Key yang dikirim PHP tetapi tidak dideklarasikan component akan menjadi fall-through attribute pada root element, yang biasanya sulit dideteksi.
Kirim scalar, array, dan null saja. Serialize Eloquent model menjadi bentuk yang memang dibutuhkan component. Model yang diserialisasi langsung dapat membawa relation/attribute apa pun yang kebetulan sudah loaded, padahal data yang menyeberang ke Vue seharusnya merupakan representasi yang sengaja dipilih.
Component
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
defineProps<{
threshold: number;
indexUrl: string;
products: Array<{
id: number;
name: string;
sku: string;
stock: number;
url: string;
}>;
}>();
</script>
<template>
<div class="flex h-full flex-col gap-3">
<ul v-if="products.length" class="flex flex-col gap-2 text-sm">
<li
v-for="product in products"
:key="product.id"
class="flex items-baseline justify-between gap-3"
>
<Link :href="product.url" class="truncate hover:underline">
{{ product.name }}
</Link>
<span
class="tabular-nums"
:class="
product.stock === 0
? 'text-destructive'
: 'text-muted-foreground'
"
>
{{ product.stock }}
</span>
</li>
</ul>
<p v-else class="text-sm text-muted-foreground">
Nothing is under {{ threshold }}.
</p>
<Link
:href="indexUrl"
class="mt-auto text-xs text-muted-foreground hover:underline"
>
All products
</Link>
</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
51
52
Widget shell sudah menggambar border, heading, description, dan filter control. Jika component merender heading sendiri, heading akan muncul dua kali.
Tempat Widget dapat digunakan
Dashboard utama Panel
Widget dapat berasal dari discovery, daftar eksplisit, atau keduanya — hasilnya digabungkan:
$panel
->discoverWidgets(app_path('Panels/Admin/Widgets'))
->widgets([LowStock::class]);2
3
PandaPanel\Pages\Dashboard merender seluruh widget yang dikenal Panel dan mengurutkannya berdasarkan $sort.
Dashboard yang memilih widget sendiri
use PandaPanel\Pages\Dashboard;
use PandaPanel\Widgets\Widget;
final class CatalogDashboard extends Dashboard
{
protected static ?string $title = 'Catalog';
protected static ?string $slug = 'catalog';
/**
* @return list<class-string<Widget>>
*/
public function widgets(): array
{
return [LowStock::class];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$panel->dashboards([Dashboard::class, CatalogDashboard::class]);Entry pertama menjadi root Panel. Dashboard berikutnya diregistrasikan sebagai page biasa dan masing-masing memiliki route, navigation item, serta filter sendiri.
Resource page
use PandaPanel\Widgets\Widget;
final class ListProducts extends ListRecords
{
protected static string $resource = ProductResource::class;
/**
* @return list<class-string<Widget>>
*/
public function headerWidgets(): array
{
return [LowStock::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
Widget yang ditempatkan pada resource page mendapat PandaPanel\Widgets\PageContext. Inilah perbedaan penting dari dashboard widget: list page memberikan query miliknya, sedangkan record page memberikan record yang sedang dibuka.
protected function context(): PageContext
// on PageContext
public function record(): ?Model
public function query(): ?Builder // the resource's own query, as the table ran it
public function count(): int // memoized2
3
4
5
6
public function data(): array
{
// The list the user is actually looking at, filters and all.
return ['showing' => $this->context()->count()];
}2
3
4
5
context() melempar exception jika context tidak tersedia, bukan mengembalikan object kosong. Widget yang mencoba membaca record padahal ditempatkan pada dashboard berada pada page yang salah; error eksplisit jauh lebih berguna daripada count 0 yang terlihat valid.
Filter
Ada dua level filter dan keduanya dikombinasikan. Dashboard dapat memfilter seluruh widget sekaligus; widget dapat memiliki filter sendiri yang hanya relevan untuk widget tersebut. Saat dibaca, hasil keduanya di-merge dan filter milik widget memiliki prioritas.
public function filterSchema(): ?FormSchema // null for most widgets
public static function filtersInModal(): bool // false — true when the form is bigger than the widget
protected function filter(string $name, mixed $default = null): mixed
protected function filters(): array2
3
4
5
filter() mengembalikan default ketika value null atau string kosong. Control yang dikosongkan diperlakukan sebagai "tidak di-set", bukan literal ''.
State filter disimpan pada query string di widgets[{widget-id}][{name}], serupa dengan state table. Tujuannya sama: dashboard terfilter tetap dapat dibagikan sebagai link dan browser back button tetap merepresentasikan state yang benar.
Semua input kembali melewati schema yang mendeklarasikannya. Schema adalah whitelist: key yang tidak pernah dideklarasikan dibuang seperti unknown form field.
Tetap lakukan clamp terhadap numeric value. Schema mempersempit key, sedangkan value tetap datang dari option yang diizinkan; max(1, min(100, …)) di data() hampir tidak memiliki biaya.
Authorization
public static function canView(): boolMethod diperiksa sebelum data() pernah dipanggil. Widget yang tidak diizinkan tidak menjalankan query sama sekali. Urutan ini penting: widget yang hanya disembunyikan setelah query masih membayar biaya query dan berpotensi membocorkan informasi melalui timing.
Method bersifat static dan tidak menerima argument, sehingga membaca authenticated user secara langsung. Page tempat widget berada tetap melakukan authorization sendiri; authorization page dan widget saling melengkapi, bukan saling menggantikan.
Lazy loading
protected static bool $lazy = true;Lazy widget diserialisasi tanpa data terlebih dahulu. Payload sebenarnya datang sebagai deferred Inertia prop, sehingga aggregate/query lambat tidak menghambat first paint seluruh dashboard.
Ada dua konsekuensi penting.
Pada response pertama, key data di definition bernilai null, sedangkan prop widgetData tidak ada sama sekali — bukan ada dengan value null. Vue component yang membaca prop tersebut harus mendeklarasikannya optional agar tidak memunculkan warning pada first paint.
Follow-up request adalah Inertia partial reload biasa dan harus membawa asset version. Tanpanya Inertia menjawab 409 dan meminta browser melakukan full visit. Client framework sudah menangani hal ini; test manual harus mengirim header yang benar:
$version = $this->get('/admin')->viewData('page')['version'];
$this->get('/admin', [
'X-Inertia' => 'true',
'X-Inertia-Version' => $version,
'X-Inertia-Partial-Component' => 'panel/Dashboard',
'X-Inertia-Partial-Data' => 'widgetData',
])->assertOk();2
3
4
5
6
7
8
Polling
protected static ?int $pollingInterval = 120; // seconds, or nullDefault-nya null. Frontend melakukan reload terhadap prop yang diberikan page, bukan meminta satu widget secara terpisah, karena data widget adalah bagian dari prop page tersebut. Queue depth yang berubah cepat mungkin layak dipoll setiap 15 detik; total yang berubah dua kali sehari tidak layak menghasilkan request terus-menerus.
Column span
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2, 'lg' => 2, 'xl' => 2];Value dapat berupa integer, string, atau map berdasarkan breakpoint. Widget::columnSpan() menormalisasikan nilai menjadi empat key (default, md, lg, xl). Value malformed melempar exception yang menyebut class widget daripada membiarkan grid rusak secara visual.
Test
<?php
declare(strict_types=1);
use App\Models\Product;
use App\Models\User;
use App\Panels\Admin\Widgets\LowStock;
use Inertia\Testing\AssertableInertia;
beforeEach(function (): void {
$this->actingAs(User::factory()->admin()->create());
});
/**
* @return array<string, mixed>|null
*/
function lowStockWidget(string $url = '/admin'): ?array
{
return collect(test()->get($url)->viewData('page')['props']['widgets'])
->firstWhere('id', LowStock::id());
}
it('is serialized with its component name', function (): void {
$widget = lowStockWidget();
expect($widget)->not->toBeNull()
->and($widget['type'])->toBe('custom')
->and($widget['component'])->toBe('Panels/Admin/Widgets/LowStock');
});
it('withholds a lazy payload from the first response', function (): void {
$widget = lowStockWidget();
expect($widget['lazy'])->toBeTrue()
->and($widget['data'])->toBeNull();
// Absent, not null: the key only exists once the follow-up lands.
expect(test()->get('/admin')->viewData('page')['props'])
->not->toHaveKey('widgetData');
});
it('resolves the payload on the follow-up request', function (): void {
Product::factory()->create(['name' => 'Keyboard', 'stock' => 2]);
Product::factory()->create(['name' => 'Monitor', 'stock' => 500]);
$version = $this->get('/admin')->viewData('page')['version'];
$this->get('/admin', [
'X-Inertia' => 'true',
'X-Inertia-Version' => $version,
'X-Inertia-Partial-Component' => 'panel/Dashboard',
'X-Inertia-Partial-Data' => 'widgetData',
])
->assertOk()
->assertJsonPath('props.widgetData.'.LowStock::id().'.products.0.name', 'Keyboard')
->assertJsonCount(1, 'props.widgetData.'.LowStock::id().'.products');
});
it('narrows to the threshold the filter asked for', function (): void {
Product::factory()->create(['name' => 'Keyboard', 'stock' => 7]);
$widget = (new LowStock)->withFilters(['threshold' => '5']);
expect($widget->data()['products'])->toBeEmpty();
});
it('is hidden, and never queries, for somebody who may not list products', function (): void {
$this->actingAs(User::factory()->create());
// The panel refuses a non-administrator outright; canView() is the
// second lock, for a widget reused somewhere less protected.
expect(LowStock::canView())->toBeFalse();
});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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
php artisan test --compact --filter=LowStocktests/Feature/Panel/WidgetRenderingTest.php adalah test versi framework terhadap empat widget example di examples/app/Panels/Admin/Widgets/.
Hal yang perlu diperhatikan
- File
.vuebaru membutuhkan rebuild. Registry adalahimport.meta.globterhadapresources/js/pages/Panels/**/Widgets/*.vuedan dievaluasi pada build time. Ini penyebab paling umum widget fallback padahal file component sudah ada. - Glob hanya mencocokkan direct child.
Widgets/Boards/LowStock.vuetidak diregistrasikan;Widgets/LowStock.vuediregistrasikan. - Nama component yang tidak dikenal merender
WidgetFallback, bukan error page. Satu typo tidak boleh menjatuhkan seluruh dashboard. Development console memberi warning satu kali per nama; production tidak. - Setiap key
data()menjadi prop. Key yang tidak dideklarasikandefinePropsakan menjadi fall-through attribute pada root element dan sering membingungkan saat debugging. canView()berjalan sebelumdata(). Jangan menaruh authorization check hanya didata()karena query sudah telanjur dijalankan.$lazymengubah shape payload.databernilai null pada response pertama danwidgetDatatidak ada sama sekali. Component yang mensyaratkan keduanya akan memberi warning pada first paint.- Widget ID berasal dari basename class. Dua widget bernama
LowStockpada namespace berbeda sama-sama menghasilkanlow-stock; collision ini memengaruhi filter state dan deferred-data key. context()melempar exception ketika tidak berada pada resource page. Widget yang membaca page context harus ditempatkan diheaderWidgets()ataufooterWidgets(), bukan dashboard.
Lihat juga
- Product Resource — data yang diringkas widget ini
- Contoh Admin Panel — empat jenis widget dalam satu Panel
- Widgets Overview
- Custom Vue Widgets
- Custom Widgets (frontend)
- Stats Widgets, Chart Widgets, Table Widgets
- Widget Filters, Lazy Loading, Polling
- Widget Layout, Widget Authorization
- Dashboards
- Component Registries
- make:panel-widget