Panel Switcher
Panel switcher adalah control di header panel yang memindahkan signed-in user antar-panel yang boleh mereka masuki. Seluruh isinya dibangun dari konfigurasi panel — tidak ada switcher API terpisah yang harus dipanggil. Gunakan halaman ini ketika ingin mengubah teks entry, menentukan siapa yang melihat panel tertentu, atau menggambar switcher sendiri melalui replacement shell.
Contoh minimal yang berfungsi
Daftarkan dua panel secara manual dan dalam urutan yang diinginkan:
// config/panda-panel.php
'panels' => [
App\Panels\Admin\AdminPanelProvider::class,
App\Panels\App\AppPanelProvider::class,
],2
3
4
5
Masing-masing panel memberi nama dirinya dan menentukan siapa yang boleh masuk:
<?php
declare(strict_types=1);
namespace App\Panels\Admin;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('admin')
->name('Administrator')
->brandName((string) config('app.name'))
->icon('shield')
->auth()
->canAccess(static fn (?Authenticatable $user): bool => $user instanceof User && $user->is_admin);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
User yang boleh masuk ke kedua panel mendapatkan switcher pada header keduanya. User yang hanya boleh mengakses satu panel tidak melihat switcher sama sekali: control menyembunyikan dirinya daripada menawarkan perpindahan ke tempat yang sedang dibuka.
Data yang dikirim server
PandaPanel\Http\Middleware\SharePanelData membagikan prop panels pada setiap panel request. Nilainya berupa closure sehingga request yang tidak pernah merender panel props tidak perlu membangunnya.
| Key | Type | Sumber |
|---|---|---|
id | string | Panel::getId() |
name | string | Panel::getName() — name(), atau Str::headline() dari id |
brandName | string | Panel::getBrandName() — brandName(), atau config('app.name') |
path | string | '/'.Panel::getPath() — path(), atau id |
icon | string|null | Panel::getIcon(), icon registry key |
darkIcon | string|null | Panel::getDarkIcon(), digunakan pada dark mode bila tersedia |
url | string | route($panel->routeName('dashboard'), absolute: false) |
current | bool | Apakah entry ini adalah panel tempat request sedang berjalan |
Di luar panel, list kosong daripada berisi seluruh panel — starter kit page sendiri tidak memiliki konteks panel yang perlu dipindahkan:
use Inertia\Testing\AssertableInertia;
$this->actingAs($admin)
->get('/')
->assertInertia(fn (AssertableInertia $page) => $page->where('panels', []));2
3
4
5
Tidak ada metadata panel lain yang dikirim melalui prop ini. brandLogo, middleware, discovery path, dan boot callback tetap berada di server. Switcher hanya membutuhkan nama, brand, path, serta pasangan icon, sehingga hanya itu yang dikirim.
Siapa melihat entry mana
List difilter menggunakan Panel::isAccessibleTo(), predicate yang sama dengan yang diterapkan route panel. Panel yang akan menolak user tidak pernah muncul sebagai destination, sehingga switcher tidak dapat menawarkan link yang kemudian menjawab 403.
public function isAccessibleTo(?Authenticatable $user): boolAda dua pertanyaan dan keduanya harus setuju:
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
// A rule about this panel.
$panel->canAccess(static fn (?Authenticatable $user): bool => $user instanceof User && $user->is_admin);2
3
4
5
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use PandaPanel\Contracts\PanelUser;
use PandaPanel\Core\Panel;
class User extends Authenticatable implements PanelUser
{
/**
* A rule about the user: a suspended account belongs in no panel,
* whatever any individual panel says.
*/
public function canAccessPanel(Panel $panel): bool
{
return $this->suspended_at === null;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
canAccess() menerima Closure(?Authenticatable): bool dan opsional — panel yang tidak memanggilnya tidak menambahkan penolakan sendiri. PanelUser juga opsional; user model yang tidak mengimplementasikannya tidak ditolak oleh contract. Keduanya tidak dapat saling melonggarkan: panel yang menjawab yes tidak dapat meng-override user model yang menjawab no.
Bentuk satu entry
Empat setter utama menentukan satu row switcher.
| Method | Signature | Default | Ditampilkan sebagai |
|---|---|---|---|
name | name(string $name): self | headline dari id | Title entry |
brandName | brandName(string $brandName): self | config('app.name') | Baris di bawah title |
path | path(string $path): self | id | Di samping brand name |
icon | icon(?string $icon): self | null | Square badge |
$panel
->name('Administrator') // "Administrator"
->brandName('Acme') // "Acme · /admin"
->path('admin')
->icon('sun', darkIcon: 'moon');2
3
4
5
Icon adalah registry key, bukan component atau path. Nama hanya di-resolve melalui resources/js/panel/icons/registry.ts; key yang tidak ada tidak merender icon sama sekali secara silent karena decorative icon tidak boleh merusak header. darkIcon digunakan hanya saat resolved appearance adalah dark, dengan fallback ke icon. Registry dihasilkan dengan:
php artisan panel:icons # rewrite it from the names the PHP declares
php artisan panel:icons --check # fail instead of writing, for CI2
Tujuan setiap entry
Semua entry mengarah ke root panel — route($panel->routeName('dashboard')), yaitu route panel.{id}.dashboard. Switcher tidak mencoba mencari page yang "setara" pada panel lain karena dua panel dapat mendaftarkan resource yang berbeda dan menerjemahkan URL antar-panel hanya akan menjadi tebakan.
$panel->routeName('dashboard'); // 'panel.admin.dashboard'
$panel->getRouteNamePrefix(); // 'panel.admin.'2
Kedua method public, sehingga aplikasi dapat membangun link yang sama seperti switcher:
use PandaPanel\Facades\PandaPanel;
$url = route(PandaPanel::get('app')->routeName('dashboard'), absolute: false); // '/app'2
3
Frontend
usePanel() mengekspos list dan satu derived question yang dibutuhkan switcher:
import { usePanel } from '@/panel/composables/usePanel';
const { panels, canSwitchPanels } = usePanel();2
3
| Binding | Type | Arti |
|---|---|---|
panels | ComputedRef<PanelSummary[]> | Daftar entry; kosong di luar panel |
canSwitchPanels | ComputedRef<boolean> | panels.length > 1 |
PanelSummary dideklarasikan di resources/js/panel/types/panel.ts dan mirror prop server secara persis.
PanelSwitcher.vue merender entry sebagai sheet — setiap entry membawa brand, name, dan path yang membutuhkan lebih banyak ruang daripada menu row biasa — dan merender nothing ketika canSwitchPanels false. Component dipasang oleh PanelHeader.vue; kedua shell merender header selama shell.topbar true, sehingga switcher tersedia baik pada sidebar shell maupun header shell.
Dua konfigurasi memengaruhi apakah built-in switcher tersedia:
$panel->topbar(false); // no bar, and so no switcher
$panel->topbarComponent('Panels/Admin/Shell/Topbar'); // a replacement top navigation2
topbarComponent() hanya digunakan oleh header shell — topNavigation() atau sidebar(variant: 'header') — tempat component mengganti navigation row di atas bar. Replacement tetap dirender meskipun topbar(false) menghapus bar bawaan, sehingga panel yang ingin menggambar satu bar sepenuhnya sendiri dapat mengatur keduanya.
Replacement menerima navigation yang sama seperti built-in row dan membaca switcher entry dari composable yang sama:
<!-- resources/js/pages/Panels/Admin/Shell/Topbar.vue -->
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { usePanel } from '@/panel/composables/usePanel';
import type { NavigationGroup } from '@/panel/types/navigation';
defineProps<{ groups: NavigationGroup[] }>();
const { panels, canSwitchPanels } = usePanel();
</script>
<template>
<header class="flex items-center gap-4">
<!-- your navigation, from `groups` -->
<nav v-if="canSwitchPanels" class="ml-auto flex items-center gap-2">
<Link
v-for="entry in panels"
:key="entry.id"
:href="entry.url"
:aria-current="entry.current ? 'page' : undefined"
>
{{ entry.name }}
</Link>
</nav>
</header>
</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
Panels/Admin/Shell/Topbar adalah build-time registry key di bawah resources/js/pages/Panels/{Panel}/Shell/, bukan path runtime. Nama yang tidak terdaftar fallback ke built-in bar agar typo tidak meninggalkan page tanpa navigation.
Memilih panel dari PHP
Switcher menjawab pertanyaan "ke panel mana lagi saya boleh pergi" untuk user yang sudah berada di dalam panel. Pertanyaan lain tersedia melalui facade:
use PandaPanel\Facades\PandaPanel;
PandaPanel::all(); // list<Panel>, sorted by id
PandaPanel::has('admin'); // bool
PandaPanel::get('admin'); // Panel, throws when unknown
PandaPanel::currentPanel(); // ?Panel for this request
PandaPanel::resolveFromRequest($request); // ?Panel, matched on domain then longest path
PandaPanel::firstAccessibleTo($user); // ?Panel — where to send somebody2
3
4
5
6
7
8
Helper panel() mencakup kasus yang paling umum:
panel(); // the panel for this request, or null
panel('admin'); // an explicit panel; throws if unknown2
firstAccessibleTo() berjalan mengikuti urutan id dan mengembalikan panel pertama yang boleh dimasuki user. Karena itu id panel adalah bagian dari policy routing: hasilnya deterministik pada setiap request dan tidak tergantung urutan config atau route mana yang kebetulan lebih dulu dijalankan. Method ini menentukan tujuan ketika request tidak menyebut panel, misalnya /dashboard milik starter kit yang diarahkan oleh PandaPanel\Support\PanelHomeRedirect:
// config/panda-panel.php
'home_redirect' => [
'enabled' => true,
'paths' => ['dashboard'],
],2
3
4
5
Administrator yang membuka /dashboard diarahkan ke /admin; member yang gagal predicate Admin diarahkan ke /app. Request yang sudah berada di panel dibiarkan, sehingga panel yang mounted pada salah satu path tersebut tidak redirect ke dirinya sendiri.
Catatan
- Switcher bukan setting tersendiri. Tidak ada
panelSwitcher(false): switcher otomatis hilang ketika user hanya dapat mengakses satu panel, dan ikut hilang bersama topbar ketika panel memanggiltopbar(false). - Closure
canAccess()pada setiap registered panel dijalankan setiap kali prop dibangun, yaitu sekali per full page render. Jaga predicate agar hanya memeriksa state yang sudah tersedia daripada menjalankan query tambahan. urlpada entry adalah path (absolute: false). Panel yang disajikan padadomain()sendiri karena itu tetap ditautkan sebagai path pada host current request, yang mungkin bukan host panel tujuan. Cross-domain switch membutuhkan replacement bar yang membangun absolute URL sendiri.brandLogodibagikan pada shell panel tetapi tidak menjadi bagian entry switcher. Badge switcher hanya menggambariconataudarkIcon.- Tenant switcher di sampingnya adalah control berbeda dengan rule berbeda — lihat Tenant Switcher.
- Behavior ini dipin oleh
tests/Feature/Panel/PanelSwitcherTest.php, yang menjadi representasi paling pendek sekaligus paling sulit menjadi stale.