Widget
Widget adalah blok ringkasan read-only yang berdiri sendiri pada sebuah panel page: deretan angka, chart, tabel singkat, atau Vue component buatan Anda sendiri. Gunakan widget ketika sebuah page perlu menjelaskan sesuatu tentang record, bukan sekadar menampilkan daftar record — misalnya berapa banyak pengguna yang mendaftar, lima order terbaru, atau apakah queue mulai menumpuk. Semua informasi yang diketahui widget dihitung di server; browser hanya menerima deskripsi ter-serialize tanpa closure dan tanpa nama class PHP.
Contoh minimal yang dapat langsung digunakan
Generate sebuah widget:
php artisan make:panel-widget UserStats --panel=Admin --type=statsKemudian isi implementasinya:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use App\Models\User;
use PandaPanel\Widgets\Enums\StatColor;
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class UserStats extends StatsWidget
{
protected static int $sort = 10;
protected static ?string $heading = 'Accounts';
/**
* @return list<Stat>
*/
public function stats(): array
{
return [
Stat::make('Total users', User::query()->count())->icon('users'),
Stat::make('Verified', User::query()->whereNotNull('email_verified_at')->count())
->icon('shield')
->color(StatColor::Success),
];
}
}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
Arahkan panel ke directory tempat widget berada:
use PandaPanel\Core\Panel;
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->path('admin')
->discoverWidgets(app_path('Panels/Admin/Widgets'));
}2
3
4
5
6
7
8
9
Sekarang GET /admin akan merender dashboard beserta widget tersebut.
Empat type widget
Setiap type menggunakan base class dengan satu kontrak utama yang perlu Anda implementasikan. Nilai type digunakan frontend untuk memilih renderer yang sesuai.
| Type | Base class | Yang Anda implementasikan | Nilai dari type() |
|---|---|---|---|
| stats | PandaPanel\Widgets\StatsWidget | stats(): list<Stat> | WidgetType::Stats ('stats') |
| table | PandaPanel\Widgets\TableWidget | table(TableSchema): TableSchema, query(): Builder | WidgetType::Table ('table') |
| chart | PandaPanel\Widgets\ChartWidget | labels(): list<string>, series(): list<ChartSeries> | WidgetType::Chart ('chart') |
| custom | PandaPanel\Widgets\CustomWidget | $component, data(): array | WidgetType::Custom ('custom') |
PandaPanel\Widgets\Enums\WidgetType adalah closed enum. Menambahkan case tanpa menyediakan Vue renderer akan menghasilkan compile error di frontend, bukan card kosong, karena WidgetRenderer.vue menangani seluruh union secara exhaustive.
Setiap type memiliki dokumentasi sendiri: Stats, Table, Chart, dan Custom Vue widget.
Lokasi widget dapat ditampilkan
Ada tiga tempat utama, dan perbedaannya terletak pada context yang diberikan kepada widget.
use PandaPanel\Pages\Dashboard;
use PandaPanel\Pages\Page;
use PandaPanel\Resources\Pages\ListRecords;
use PandaPanel\Resources\Resource;
use PandaPanel\Widgets\Widget;
// 1. The panel dashboard: every widget in the panel's registry.
// PandaPanel\Pages\Dashboard::widgets() reads the registry.
// 2. Any standalone page, by naming classes.
final class Reports extends Page
{
/** @return list<class-string<Widget>> */
public function widgets(): array
{
return [RevenueChart::class];
}
}
// 3. A resource page, above or below its own content.
final class ListOrders extends ListRecords
{
/** @return list<class-string<Widget>> */
public function headerWidgets(): array
{
return [OrderStats::class];
}
/** @return list<class-string<Widget>> */
public function footerWidgets(): array
{
return [];
}
}
// Or declare resource-wide widgets once. The standard index page places
// getWidgets() in its header; individual pages can still override.
final class OrderResource extends Resource
{
/** @return list<class-string<Widget>> */
public static function getWidgets(): array
{
return [OrderStats::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
36
37
38
39
40
41
42
43
44
45
Dashboard atau standalone page memberikan filter kepada widget, tetapi tidak memberikan page context. Resource page memberikan page context dan filter pada level widget — lihat Filter dan catatan di bawah.
Mendaftarkan widget
/** @param list<class-string> $widgets */
public function widgets(array $widgets): self
public function discoverWidgets(string ...$paths): self2
3
4
use App\Panels\Admin\Widgets\UserStats;
$panel
->widgets([UserStats::class])
->discoverWidgets(app_path('Panels/Admin/Widgets'));2
3
4
5
Kedua daftar digabungkan menjadi satu PandaPanel\Core\WidgetRegistry per panel dengan widget id sebagai key. Karena itu class yang didaftarkan secara eksplisit sekaligus ditemukan melalui discovery tetap hanya diregistrasikan satu kali. Discovery mencari class yang mengimplementasikan PandaPanel\Contracts\WidgetContract di bawah path yang diberikan. Lihat Discovery.
Widget id hanya diturunkan dari nama class:
public static function id(): string // Str::kebab(class_basename(static::class))App\Panels\Admin\Widgets\RecentUsers menjadi recent-users. Dua widget dalam satu panel yang menghasilkan id sama akan melempar PanelRegistrationException::duplicateWidgetId() saat registration. Id harus unik karena digunakan sebagai key untuk deferred payload, group filter, dan namespace query string milik table widget.
Jika perlu, registry dapat dibaca langsung:
use PandaPanel\Core\PanelManager;
app(PanelManager::class)->widgets('admin')->all(); // list<class-string>, sorted
app(PanelManager::class)->widgets('admin')->byId('recent-users');
app(PanelManager::class)->widgets('admin')->has('recent-users');
app(PanelManager::class)->widgets('admin')->count();2
3
4
5
6
Generator
php artisan make:panel-widget {name} --panel=Admin [--type=stats] [--force]| Option | Value | Default |
|---|---|---|
--panel | nama panel; otomatis diubah ke StudlyCase | wajib |
--type | stats, table, chart, custom | stats |
--force | menimpa file yang sudah ada | nonaktif |
Class ditulis ke app/Panels/{Panel}/Widgets/{Name}.php. Untuk --type=custom, generator juga menulis Vue component ke resources/js/pages/Panels/{Panel}/Widgets/{Name}.vue, karena custom widget tanpa component hanya akan menampilkan fallback. --type yang tidak dikenal membuat command gagal tanpa menulis file apa pun. Lihat make:panel-widget.
API yang digunakan bersama
Seluruh member berikut berada pada PandaPanel\Widgets\Widget dan berlaku untuk keempat type widget.
| Member | Signature | Default | Fungsi |
|---|---|---|---|
$sort | protected static int | 0 | Urutan pada page, ascending. |
$columnSpan | protected static int|string|array | 1 | Lebar pada grid. Lihat Layout. |
$lazy | protected static bool | false | Menunda data() dari response pertama. Lihat Lazy loading. |
$heading | protected static ?string | null | Judul di atas widget. |
$description | protected static ?string | null | Deskripsi singkat di bawah heading. |
$pollingInterval | protected static ?int | null | Jeda refresh dalam detik. Lihat Polling. |
id() | public static function id(): string | basename class dalam kebab-case | Identitas stabil widget. |
sort() | public static function sort(): int | $sort | |
isLazy() | public static function isLazy(): bool | $lazy | |
heading() | public static function heading(): ?string | $heading | |
description() | public static function description(): ?string | $description | |
pollingInterval() | public static function pollingInterval(): ?int | $pollingInterval | |
columnSpan() | public static function columnSpan(): array | $columnSpan yang sudah dinormalisasi | Satu value per breakpoint. |
canView() | public static function canView(): bool | true | Diperiksa sebelum data(). Lihat Otorisasi. |
type() | abstract public static function type(): WidgetType | — | Disediakan oleh base class yang Anda extend. |
data() | abstract public function data(): array | — | Payload widget. Hanya scalar, array, dan null. |
filterSchema() | public function filterSchema(): ?FormSchema | null | Lihat Filter. |
filtersInModal() | public static function filtersInModal(): bool | false | Menempatkan form filter dalam dialog. |
withFilters() | public function withFilters(array $filters): static | — | Dipanggil oleh page. |
filter() | protected function filter(string $name, mixed $default = null): mixed | — | Membaca satu value filter. |
filters() | protected function filters(): array | — | Membaca seluruh filter. |
withPageContext() | public function withPageContext(PageContext $context): static | — | Dipanggil oleh page. |
context() | protected function context(): PageContext | — | Melempar exception jika context tidak tersedia. |
toDefinition() | public function toDefinition(): array | — | Bentuk widget yang diserialisasi. |
toArray() | public function toArray(): array | — | Alias dari toDefinition(). |
use PandaPanel\Widgets\StatsWidget;
final class QueueDepth extends StatsWidget
{
protected static int $sort = 5;
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2];
protected static bool $lazy = true;
protected static ?string $heading = 'Queue';
protected static ?string $description = 'Jobs waiting to run.';
protected static ?int $pollingInterval = 15;
public static function canView(): bool
{
return auth()->user()?->can('view-operations') === true;
}
public function stats(): array { /* ... */ }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$heading, $description, $sort, $columnSpan, $lazy, dan $pollingInterval bersifat static, sehingga nilainya sama untuk setiap request. Informasi yang harus berbeda per user sebaiknya ditempatkan di data() atau ditentukan melalui canView().
Data yang dikirim ke browser
toDefinition() merupakan seluruh kontrak data antara backend dan frontend:
[
'id' => 'user-stats',
'type' => 'stats',
'sort' => 10,
'columnSpan' => ['default' => 1, 'md' => 2, 'lg' => 3, 'xl' => 4],
'lazy' => false,
'heading' => 'Accounts',
'description' => null,
'polling' => 60, // seconds, or null
'filters' => null, // or ['inModal' => bool, 'form' => FormDefinition]
'data' => ['stats' => [/* ... */]], // null when lazy
]2
3
4
5
6
7
8
9
10
11
12
CustomWidget menambahkan satu key, yaitu component. Tidak ada key lain yang ditambahkan, dan tidak ada nama class PHP yang dikirim. Package memiliki test yang secara eksplisit memastikan hal tersebut.
Cara page me-resolve widget
PandaPanel\Pages\WidgetCollection menjalankan proses berikut secara berurutan:
canView()dipanggil pada class. Widget yang menolak akses dibuang sebelum dibuat, sehingga query miliknya tidak pernah dijalankan.- Widget yang lolos kemudian dibuat, diberikan page context jika tersedia, dan diberikan value filter jika page berhasil me-resolve filter.
- Widget diurutkan berdasarkan
[sort(), id()]. Id menjadi tiebreaker agar widget dengan$sortsama tetap memiliki urutan yang stabil. definitions()men-serialize setiap widget, menjalankandata()langsung untuk eager widget dan mengisinulluntuk lazy widget.deferred()mengembalikan satu propInertia::defer()berisi{widgetId: data}untuk seluruh lazy widget, ataunulljika tidak ada lazy widget.
use PandaPanel\Pages\WidgetCollection;
use PandaPanel\Widgets\PageContext;
use PandaPanel\Widgets\Support\WidgetFilters;
$collection = WidgetCollection::for(
[UserStats::class, RecentUsers::class],
PageContext::forRecord($order), // optional
WidgetFilters::none(), // optional
);
$collection->definitions(); // list<array<string, mixed>>
$collection->deferred(); // Inertia deferred prop, or null
$collection->merge($other); // one collection for a single deferred prop2
3
4
5
6
7
8
9
10
11
12
13
Props yang dikirim oleh page:
| Page | Definition props | Deferred prop |
|---|---|---|
Dashboard / Page | widgets | widgetData |
| resource page | headerWidgets, footerWidgets | widgetData |
Page context
Widget pada resource page menerima PandaPanel\Widgets\PageContext yang menjelaskan data apa yang sedang ditampilkan page tersebut.
public static function forRecord(Model $record): self
public static function forQuery(Closure $query): self
public function record(): ?Model
public function query(): ?Builder
public function count(): int2
3
4
5
6
ListRecords membangunnya melalui PageContext::forQuery() dari query yang benar-benar digunakan tabel, termasuk tab scoping. Dengan demikian widget menghitung record yang sedang dilihat user, bukan seluruh table. ViewRecord, EditRecord, dan ManageRelatedRecords membangunnya melalui PageContext::forRecord().
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class SelectionSummary extends StatsWidget
{
public function stats(): array
{
return [
Stat::make('Matching orders', $this->context()->count()),
];
}
}2
3
4
5
6
7
8
9
10
11
12
count() di-memoize pada context object dan semua widget pada page menggunakan instance context yang sama. Tiga widget yang sama-sama meminta count hanya menjalankan satu query; jika tidak ada widget yang memanggil count, tidak ada query count yang dijalankan.
context() melempar LogicException jika widget dirender tanpa context. Perilaku ini disengaja: widget yang mencoba membaca record yang sebenarnya tidak pernah diberikan berarti ditempatkan di page yang salah, dan mengembalikan nol justru akan menyembunyikan masalah. Dashboard dan standalone page memang tidak memberikan context.
Hal yang perlu diperhatikan
canView()bersifat static dan tidak menerima argumen. Method tersebut berjalan sebelum object widget tersedia, sehingga tidak dapat melihat record milik page. Jika visibilitas bergantung pada record, lakukan keputusan di dalamdata()atau jangan sertakan widget dalamheaderWidgets().Resource::getWidgets()merupakan convenience untuk index page. GunakangetHeaderWidgets(string $page)ataugetFooterWidgets(string $page)pada resource ketika view, edit, atau relation page membutuhkan widget berbeda.- Filter widget pada resource page diingat berdasarkan panel, resource, page, dan record. Filter saat melihat satu record tidak akan dipulihkan saat melihat record lain.
- Data widget tidak pernah di-cache oleh
panel:cache. Manifest hanya menyimpan nama class; count, row, dan series tetap dihitung per request. widgetDataabsent pada response pertama, bukan bernilai null, karena merupakan deferred prop. Vue component yang membacanya harus mendeklarasikan prop tersebut sebagai optional.- Dua widget yang basename class-nya menghasilkan kebab-case sama tidak dapat berada pada satu panel.
App\Panels\Admin\Widgets\UserStatsdanApp\Panels\Admin\Reports\UserStatssama-sama menjadiuser-stats, sehingga registration widget kedua akan melempar exception. - Tidak ada testing helper khusus widget. Widget diuji melalui Inertia props milik page, sama seperti
WidgetRenderingTestmilik package.