Tenant Switcher
Tenant switcher adalah dropdown pada header panel yang memungkinkan user berpindah di antara tenant yang mereka miliki. Server membangun daftar tersebut — PandaPanel\Http\Middleware\SharePanelData membagikannya melalui prop tenancy — lalu PanelTenantSwitcher.vue merendernya. Komponen ini muncul otomatis setelah panel memiliki tenancy, URL builder, dan user yang menjadi anggota lebih dari satu tenant; tidak ada komponen tambahan yang perlu didaftarkan.
Mengaktifkannya
use App\Models\Workspace;
use Illuminate\Http\Request;
use PandaPanel\Core\Panel;
$panel
->tenant(
Workspace::class,
static fn (Request $request): ?Workspace => Workspace::query()
->find($request->query('workspace')),
)
->tenantUrlUsing(
static fn (Workspace $workspace, Panel $panel): string => '/'
.$panel->getPath().'/documents?workspace='.$workspace->getKey(),
);2
3
4
5
6
7
8
9
10
11
12
13
14
tenant() menghasilkan daftar tenant. tenantUrlUsing() membuat setiap entry dapat diklik; tanpa URL builder, switcher tidak dirender sama sekali — lihat URL Tenant.
Tiga kondisi
Switcher hanya dirender jika seluruh kondisi berikut terpenuhi:
| Kondisi | Ditentukan oleh |
|---|---|
| Panel mendeklarasikan tenancy | Panel::tenant() — jika tidak, tenancy bernilai null |
| User boleh masuk ke lebih dari satu tenant | HasPanelTenants::getPanelTenants() mengembalikan minimal dua tenant |
| Setidaknya satu entry memiliki URL | Panel::tenantUrlUsing() sudah dipanggil |
canSwitchTenants: computed(
() =>
(tenancy.value?.available.length ?? 0) > 1 &&
(tenancy.value?.available ?? []).some(
(entry) => entry.url !== null,
),
),2
3
4
5
6
7
User yang hanya menjadi anggota satu tenant tidak melihat switcher karena memang tidak ada tujuan untuk berpindah. Panel yang tidak pernah mendefinisikan cara membangun URL tenant juga tidak menampilkan switcher, karena daftar tujuan yang tidak dapat dinavigasi lebih buruk daripada tidak menampilkan switcher sama sekali.
Shared prop
SharePanelData membagikan tenancy pada setiap request web. Nilainya dibangun melalui closure, sehingga halaman panel yang tidak membutuhkan data tersebut tidak menjalankan query di belakangnya.
/**
* @return array{
* current: array{key: int|string, name: string, url: string|null, current: bool}|null,
* available: list<array{key: int|string, name: string, url: string|null, current: bool}>
* }|null
*/
private function tenancy(Request $request): ?array
{
$panel = $this->manager->currentPanel();
if ($panel === null || ! $panel->hasTenancy()) {
return null;
}
$current = Tenancy::current();
$currentKey = $current === null ? null : Tenancy::keyOf($current);
$describe = static fn (Model $tenant): array => [
...Tenancy::describe($tenant),
'url' => $panel->getTenantUrl($tenant),
'current' => $currentKey !== null && Tenancy::keyOf($tenant) === $currentKey,
];
return [
'current' => $current === null ? null : $describe($current),
'available' => array_map(
$describe,
Tenancy::availableTo($request->user(), $panel),
),
];
}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
| Field | Tipe | Arti |
|---|---|---|
current | entry atau null | Tenant yang sedang terikat pada request ini |
available | daftar entry | Semua tenant yang boleh dimasuki user, berdasarkan getPanelTenants() |
key pada entry | int | string | Tenancy::keyOf() |
name pada entry | string | Tenancy::nameOf() |
url pada entry | string | null | Panel::getTenantUrl(), null jika URL builder tidak dideklarasikan |
current pada entry | bool | Apakah key identik dengan tenant yang sedang terikat |
Untuk panel tanpa tenancy, seluruh prop bernilai null, bukan object kosong. Karena itu frontend cukup memeriksa tenancy === null, dan aplikasi tanpa tenancy tidak merender elemen yang berkaitan dengan tenant.
Daftar available berasal dari jawaban model user sendiri. Hal ini mencegah switcher menawarkan tenant yang ketika diklik hanya akan menghasilkan 403.
Membacanya di Vue
import { usePanel } from '@/panel/composables/usePanel';
const { tenancy, canSwitchTenants } = usePanel();2
3
| Property | Tipe | Catatan |
|---|---|---|
tenancy | ComputedRef<PanelTenancy | null> | null untuk panel tanpa tenancy |
canSwitchTenants | ComputedRef<boolean> | Mengikuti tiga kondisi di atas |
export interface PanelTenantSummary {
key: string | number;
name: string;
url: string | null;
current: boolean;
}
export interface PanelTenancy {
current: PanelTenantSummary | null;
available: PanelTenantSummary[];
}2
3
4
5
6
7
8
9
10
11
Kedua tipe berada di resources/js/panel/types/panel.ts. Akses data melalui usePanel(), bukan usePage() secara langsung — panelSharedProps() menjadi satu-satunya tempat cast pada frontend panel, dan contract test memastikan tidak ada file lain di bawah resources/js/panel yang membaca key tersebut langsung dari usePage().
Komponen bawaan
resources/js/panel/components/PanelTenantSwitcher.vue dirender oleh PanelHeader.vue, di antara search palette dan panel switcher.
Komponen tenant menggunakan dropdown, bukan sheet seperti panel switcher. Perbedaannya bukan sekadar kosmetik: entry panel membawa brand, icon, dan path sehingga membutuhkan ruang lebih besar, sedangkan tenant pada dasarnya hanya membutuhkan nama.
Setiap entry menggunakan <a> biasa, bukan <Link> milik Inertia. Tenant sering berada di host yang berbeda, dan Inertia visit lintas-origin akan ditolak browser; navigasi penuh adalah mekanisme yang benar. Entry dengan url bernilai null ditampilkan sebagai teks disabled, bukan link.
Merender switcher sendiri
Shared prop adalah public contract, sehingga custom shell atau topbar Anda dapat merender data yang sama dengan bentuk UI apa pun:
<script setup lang="ts">
import { usePanel } from '@/panel/composables/usePanel';
const { tenancy, canSwitchTenants } = usePanel();
</script>
<template>
<nav v-if="canSwitchTenants" aria-label="Tenants">
<a
v-for="tenant in tenancy?.available ?? []"
:key="tenant.key"
:href="tenant.url ?? undefined"
:aria-current="tenant.current ? 'true' : undefined"
>
{{ tenant.name }}
</a>
</nav>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Tempatkan komponen melalui render hook atau custom topbar, bukan dengan mengedit komponen package secara langsung — lihat Render Hook dan Custom Shell.
Informasi yang lebih kaya dari sekadar nama — misalnya logo, badge plan, atau jumlah member — adalah prop milik aplikasi Anda. Tenancy::describe() sengaja hanya mengembalikan dua key dan tidak menyediakan hook untuk menambahkan key ketiga; bagikan data tambahan melalui HandleInertiaRequests milik aplikasi lalu baca bersama tenancy.
Mengujinya
use Inertia\Testing\AssertableInertia;
it('gives every offered tenant a url and marks the current one', function (): void {
$this->get('/app/documents?workspace='.$acme->getKey())
->assertInertia(fn (AssertableInertia $page) => $page
->where('tenancy.current.name', 'Acme')
->where('tenancy.available.0.current', true)
->where('tenancy.available.1.current', false)
->where('tenancy.available.1.url', '/app/documents?workspace='.$beta->getKey()));
});
it('offers no tenant this user does not belong to', function (): void {
$this->get('/app/documents?workspace='.$acme->getKey())
->assertInertia(fn (AssertableInertia $page) => $page
->has('tenancy.available', 1)
->where('tenancy.available.0.name', 'Acme'));
});
it('shares nothing at all for a panel with no tenancy', function (): void {
$response = $this->actingAs($admin)->get('/admin');
expect($response->viewData('page')['props']['tenancy'])->toBeNull();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Catatan
- Switcher melakukan navigasi, bukan POST. Tidak ada endpoint "switch tenant" dan tidak ada session state yang perlu diubah. Lokasi tenant adalah sebuah URL, sehingga berpindah tenant berarti melakukan navigasi — mekanisme yang sama dapat bekerja untuk subdomain, segmen path, maupun query parameter.
currentdapat bernilainullsementaraavailableberisi data. Kondisi ini dapat terjadi pada login page milik panel karena guest route didaftarkan tanpaResolveTenant, sehingga belum ada tenant yang terikat.- Urutan
availableadalah urutan yang Anda tentukan.Tenancy::availableTo()mempertahankan urutan yang dikembalikangetPanelTenants(). TambahkanorderByjika urutan penting. - Key dibandingkan menggunakan
===setelah melaluiTenancy::keyOf(). Model yang mengembalikan'7'darigetTenantKey()sementara tenant aktif menghasilkan7tidak akan pernah ditandai sebagai current. Lakukan cast secara konsisten. - Tidak ada entry "tanpa tenant". Authenticated route pada panel dengan tenancy selalu memiliki satu tenant yang terikat; jika tidak, request sudah lebih dulu menghasilkan 404.