Cluster
Cluster adalah sekumpulan Resource dan Page yang saling berkaitan, berada di bawah satu URL prefix dan satu bagian navigation. Semua member di dalamnya hidup di bawah slug cluster — misalnya /admin/ops/tasks — dan setiap page merender sub-navigation milik cluster, sehingga berpindah antar-sibling tidak mengharuskan user kembali ke sidebar. Gunakan cluster ketika sebuah Panel sudah memiliki sekumpulan screen yang sebenarnya merupakan satu area: settings, operations, atau satu rangkaian report.
Prefix tidak mengubah nama route. Sebuah Resource tetap bernama panel.admin.resources.roles.index, sehingga semua Resource::url() yang sudah ditulis tetap bekerja; hanya path yang dihasilkan yang berpindah. Inilah yang membuat adopsi cluster menjadi perubahan yang tidak merusak kode existing.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Clusters;
use PandaPanel\Clusters\Cluster;
use PandaPanel\Enums\ClusterPosition;
final class OperationsCluster extends Cluster
{
protected static ?string $navigationIcon = 'settings';
protected static ClusterPosition $position = ClusterPosition::Header;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
Membership dideklarasikan oleh member, bukan didaftarkan pada cluster:
use App\Panels\Admin\Clusters\OperationsCluster;
use PandaPanel\Clusters\Cluster;
use PandaPanel\Resources\Resource;
final class TaskResource extends Resource
{
/** @var class-string<Cluster>|null */
protected static ?string $cluster = OperationsCluster::class;
}2
3
4
5
6
7
8
9
use App\Panels\Admin\Clusters\OperationsCluster;
use PandaPanel\Clusters\Cluster;
use PandaPanel\Pages\Page;
final class Throughput extends Page
{
/** @var class-string<Cluster>|null */
protected static ?string $cluster = OperationsCluster::class;
}2
3
4
5
6
7
8
9
Sidebar sekarang menampilkan satu item Operations yang dapat di-expand menjadi Tasks dan Throughput. Keduanya berada di bawah /admin/operations/…, dan keduanya merender bar di bawah page header yang menghubungkan satu sama lain.
Tidak ada generator untuk cluster dan tidak ada registration call: cluster ditemukan melalui member yang menyebut class cluster tersebut.
Mengapa membership menunjuk ke atas
Setiap class membawa sendiri informasi tempatnya di Panel. Jika cluster menyimpan daftar member, itu menjadi daftar kedua yang dapat berbeda dengan deklarasi pada member dan akan drift saat seseorang memindahkan class. ClusterNavigation membangun isi setiap cluster dengan bertanya kepada registry Panel class mana saja yang menyebut cluster tersebut.
Class Cluster
Semua property bersifat protected static; semua accessor bersifat public static.
| Property | Type | Default | Accessor |
|---|---|---|---|
$title | ?string | Str::headline(class basename minus "Cluster") | title() |
$slug | ?string | Str::kebab(class basename minus "Cluster") | slug() |
$navigationIcon | ?string | null | navigationIcon() |
$activeNavigationIcon | ?string | $navigationIcon | activeNavigationIcon() |
$navigationGroup | string|BackedEnum|null | null | navigationGroup() |
$navigationSort | int | 0 | navigationSort() |
$shouldRegisterNavigation | bool | true | shouldRegisterNavigation() |
$position | ClusterPosition | ClusterPosition::Header | position() |
use PandaPanel\Clusters\Cluster;
use PandaPanel\Enums\ClusterPosition;
final class ReportsCluster extends Cluster {}
ReportsCluster::title(); // 'Reports'
ReportsCluster::slug(); // 'reports'
ReportsCluster::position(); // ClusterPosition::Header
ReportsCluster::navigationIcon(); // null
ReportsCluster::activeNavigationIcon(); // null
ReportsCluster::shouldRegisterNavigation(); // true2
3
4
5
6
7
8
9
10
11
Suffix Cluster dihapus dari kedua default, sehingga OperationsCluster memiliki title Operations dan slug operations. Deklarasikan $slug bila membutuhkan path yang lebih pendek:
final class OperationsCluster extends Cluster
{
protected static ?string $title = 'Operations';
protected static ?string $slug = 'ops';
protected static ?string $navigationIcon = 'settings';
protected static ?string $activeNavigationIcon = 'shield';
protected static string|BackedEnum|null $navigationGroup = 'System';
protected static int $navigationSort = 90;
}2
3
4
5
6
7
8
9
10
11
12
13
14
canAccess()
public static function canAccess(): bool; // default truePemeriksaan ini independen dari member cluster. Cluster yang tidak boleh dimasuki user menyembunyikan seluruh kumpulan dan tidak menghasilkan sub-navigation bar, tetapi setiap member tetap melakukan authorization sendiri. Menyembunyikan navigation bukan kontrol keamanan.
public static function canAccess(): bool
{
return auth()->user()?->can('viewOperations') === true;
}2
3
4
navigationItem()
public static function navigationItem(Panel $panel): ?NavigationItem; // default nullDefault-nya null, dan ini justru kasus yang paling berguna. Cluster adalah container, sehingga ketika diklik seharusnya menuju member visible pertama. Hanya navigation builder yang mengetahui member mana yang visible karena builder-lah yang melakukan filtering authorization. Mengembalikan null memberi builder kebebasan menentukan tujuan tersebut.
Override ketika cluster memiliki landing page sendiri:
use PandaPanel\Core\Panel;
use PandaPanel\Support\NavigationItem;
public static function navigationItem(Panel $panel): ?NavigationItem
{
return NavigationItem::make(
label: static::title(),
href: OperationsOverview::url($panel),
icon: static::navigationIcon(),
sort: static::navigationSort(),
group: static::navigationGroup(),
activeIcon: static::activeNavigationIcon(),
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
Override bertanggung jawab atas children-nya sendiri. Builder menggunakan item yang dikembalikan apa adanya, sehingga item yang dibuat tanpa children: tidak memiliki apa pun untuk di-expand.
ClusterPosition
namespace PandaPanel\Enums;
enum ClusterPosition: string
{
case Header = 'header'; // a bar under the header, above the page content
case RightBar = 'right-bar'; // a column beside the content, on the right
case Sidebar = 'sidebar'; // only in the sidebar, under the cluster's own item
}2
3
4
5
6
7
8
Enum ini closed karena setiap case dipetakan ke lokasi shell yang sudah dikenal build. Tempat sekumpulan page ditampilkan adalah keputusan layout yang dibuat Panel satu kali, bukan keputusan terpisah pada setiap page.
Sidebar tidak merender bar pada page sama sekali. Member cluster tetap di-expand di bawah item cluster pada sidebar, yang untuk cluster kecil sering kali sudah cukup.
Routing
public static function routePath(): string; // on PageroutePath() milik Page menambahkan slug cluster sebagai prefix:
$cluster === null ? static::slug() : $cluster::slug().'/'.static::slug();Resource diberi prefix oleh route registrar dengan pola yang sama — 'prefix' => $cluster::slug().'/'.$slug — sementara route name tetap resources.{slug}..
ClusteredReportPage::routePath(); // 'ops/throughput'
ClusteredReportPage::url($panel); // '/cluster-host/ops/throughput'
ClusteredTaskResource::url(panel: $panel); // '/cluster-host/ops/clustered-tasks'
Route::has('panel.cluster-host.resources.clustered-tasks.index'); // true2
3
4
5
Memindahkan class ke dalam cluster karena itu hanya mengubah URL, bukan nama route atau API. Bookmark lama dapat rusak, tetapi kode yang membangun URL melalui route name tetap bekerja.
ClusterNavigation
namespace PandaPanel\Support;
/** @return array<class-string<Cluster>, list<NavigationItem>> */
public static function all(Panel $panel): array;
/**
* @param class-string<Cluster> $cluster
* @return array{label: string, icon: string|null, position: string, items: list<array<string, mixed>>}|null
*/
public static function for(Panel $panel, string $cluster, string $currentPath): ?array;2
3
4
5
6
7
8
9
10
all() menelusuri registry Resource dan Page milik Panel, mempertahankan semua class yang menyebut sebuah cluster dan lolos authorization-nya sendiri (canViewAny() untuk Resource, canAccess() untuk Page), lalu mengurutkan item setiap cluster berdasarkan [sort, label].
use PandaPanel\Support\ClusterNavigation;
$items = ClusterNavigation::all($panel)[OperationsCluster::class] ?? [];
$items[0]->href; // '/cluster-host/ops/clustered-tasks'2
3
4
5
for() adalah data yang dikirim sebuah page. Method ini menghasilkan null pada dua kondisi: cluster menolak canAccess(), atau tidak ada member yang visible untuk user tersebut. Karena itu cluster bar kosong tidak pernah dirender.
$cluster = ClusterNavigation::for($panel, OperationsCluster::class, 'cluster-host/ops/throughput');
$cluster['label']; // 'Operations'
$cluster['position']; // 'header'
array_column($cluster['items'], 'label'); // ['Tasks', 'Throughput']
collect($cluster['items'])->firstWhere('active', true)['label']; // 'Throughput'2
3
4
5
6
Active state memakai prefix match, sehingga /admin/ops/tasks/3/edit tetap menandai Tasks sebagai active — rule yang sama seperti sidebar.
Di sidebar
PandaPanel\Support\NavigationBuilder membangun cluster lebih dahulu karena tahap setelahnya harus mengetahui class mana yang sudah terwakili:
- sebuah cluster menjadi satu navigation item yang dapat di-expand ke member;
- member tidak didaftarkan ulang di samping item cluster;
hrefitem cluster adalah$children[0]->href, yaitu member visible pertama;- cluster dengan
canAccess()false ataushouldRegisterNavigation()false tidak menghasilkan item; - cluster tanpa member visible tidak menghasilkan apa pun.
it('lists a cluster once, with its members as children', function (): void {
// One item that expands, not one item per member beside it.
expect(array_column($cluster['children'], 'label'))->toBe(['Tasks', 'Throughput'])
->and($items->firstWhere('label', 'Tasks'))->toBeNull();
});2
3
4
5
$navigationGroup milik cluster menempatkan satu item tersebut, sehingga cluster dapat berada di dalam sidebar group seperti navigation item lain. Lihat Navigation groups.
Pada page
Standalone page dan resource page sama-sama memasukkan cluster bar ke metadata:
// Page::metadata()
'cluster' => static::$cluster === null
? null
: ClusterNavigation::for($this->panel(), static::$cluster, request()->path()),
// ResourcePage::clusterNavigation()
protected function clusterNavigation(): ?array;2
3
4
5
6
7
export type ClusterPosition = 'header' | 'right-bar' | 'sidebar';
export interface ClusterNavigation {
label: string;
icon: string | null;
position: ClusterPosition;
items: NavigationItem[];
}2
3
4
5
6
7
8
SidebarPanelLayout dan HeaderPanelLayout membaca page.cluster dan merender PanelClusterBar.vue:
position | Rendering |
|---|---|
header | orientation="row" di atas page content, dengan bottom border |
right-bar | orientation="column" pada kolom 14rem di sisi kanan content |
sidebar | tidak ada apa pun pada page |
normalizePageMetadata() melakukan narrowing ketika data melewati boundary: cluster tanpa item menjadi null, sedangkan position yang tidak dikenali fallback ke header daripada masuk ke branch yang tidak tersedia.
Gotchas
- Nested Resource kehilangan cluster prefix pada path-nya. Route registrar membangun prefix nested Resource sebagai
{parentSlug}/{parent}/{slug}, yang menggantikan cluster prefix. Resource tetap terdaftar di bawah cluster dan tetap mendapatkan cluster bar; hanya URL yang berada di bawah parent. - Cluster bar pada page tidak diprefetch dan tidak pernah ditandai
fullPage.fullPageUrls()diterapkan olehNavigationBuilder, yang membangun sidebar. Cluster bar pada page berasal dariClusterNavigation::for()dan selalu merender client-side<Link>. Lihat Full page URLs. - Cluster bukan route. Tidak ada
/admin/opskecuali salah satu member memang mengklaim path tersebut. Item sidebar cluster mengarah ke member visible pertama. Cluster::canAccess()tidak meng-authorize member. Method ini hanya mengendalikan sidebar item dan bar. Member yang benar-benar harus ditutup tetap membutuhkancanAccess()atau policy sendiri.- Dua cluster tidak boleh berbagi slug karena path member-nya akan collision. Tidak ada pemeriksaan saat boot; collision muncul sebagai satu route menutupi route lain.
- Slug dan title hanya menghapus suffix
Clusterdi bagian akhir.OpsClustermenjadiops;ClusterOpsmenjadicluster-ops. - Member diurutkan di dalam cluster berdasarkan
[sort, label], rule yang sama seperti sidebar di dalam sebuah group. Yang digunakan adalah$navigationSortpada member, bukan pada cluster.