Sub Navigation
Sub Navigation adalah kumpulan link untuk berpindah di antara Page milik satu record — view, edit, dan relation Page apa pun yang dideklarasikan Resource. Data dibangun oleh server dari map pages() milik Resource, di-authorize per record, lalu dikirim bersama Page sebagai page.subNavigation. Tidak ada deklarasi ganda: Resource yang tidak memiliki view Page juga tidak memiliki link menuju view.
Bar milik cluster adalah fitur berbeda yang memiliki nama mirip; lihat Clusters.
Contoh minimal yang berfungsi
Tidak ada fitur yang perlu diaktifkan. Resource yang mendeklarasikan kedua record Page otomatis mendapatkan bar:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Users;
use App\Panels\Admin\Resources\Users\Pages\CreateUser;
use App\Panels\Admin\Resources\Users\Pages\EditUser;
use App\Panels\Admin\Resources\Users\Pages\ListUsers;
use App\Panels\Admin\Resources\Users\Pages\ViewUser;
use PandaPanel\Resources\Resource;
final class UserResource extends Resource
{
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListUsers::class,
'create' => CreateUser::class,
'view' => ViewUser::class,
'edit' => EditUser::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
Membuka /admin/users/3 sekarang merender tab strip View | Edit dengan View active. Membuka /admin/users/3/edit merender strip yang sama dengan Edit active.
RecordSubNavigation
namespace PandaPanel\Support;
/**
* @param class-string<PanelResource> $resource
* @return list<array{key: string, label: string, href: string, icon: string|null, active: bool}>
*/
public static function for(string $resource, Model $record, string $currentPage): array;2
3
4
5
6
7
$currentPage adalah key Page yang sedang dirender caller — 'view', 'edit', atau key milik relation manager.
use App\Panels\Admin\Resources\Users\UserResource;
use PandaPanel\Support\RecordSubNavigation;
RecordSubNavigation::for(UserResource::class, $record, 'view');2
3
4
[
['key' => 'view', 'label' => 'View', 'href' => '/admin/users/3', 'icon' => 'search', 'active' => true],
['key' => 'edit', 'label' => 'Edit', 'href' => '/admin/users/3/edit', 'icon' => 'settings', 'active' => false],
]2
3
4
Item yang tersedia
Hanya record Page yang ikut serta. Page yang route-nya tidak membawa {record} — seperti index dan create Page — bukan tempat record tersebut dapat dilihat.
Dua key tetap
Sebuah private constant di dalam RecordSubNavigation hanya menyimpan dua key berikut:
| Key | Dimasukkan ketika | Ability | Icon | Label |
|---|---|---|---|---|
view | pages() memiliki key view | Resource::canView($record) | search | View |
edit | pages() memiliki key edit | Resource::canEdit($record) | settings | Edit |
Label berasal dari Str::headline($key), sehingga menjadi View dan Edit dan tidak configurable per Resource.
Relation Page
Setiap Page PandaPanel\Resources\Pages\ManageRelatedRecords di dalam pages() ditemukan secara dinamis daripada didaftarkan pada fixed list, karena ability-nya bergantung pada relation manager yang dirujuk — fixed map tidak dapat menjawab hal tersebut.
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListUsers::class,
'view' => ViewUser::class,
'edit' => EditUser::class,
'posts' => ManageUserPosts::class, // extends ManageRelatedRecords
];
}2
3
4
5
6
7
8
9
10
11
12
Tiga pemeriksaan menentukan apakah relation Page muncul:
- Page merupakan subclass
ManageRelatedRecords; - Resource mendeklarasikan manager tersebut —
Resource::relationManager($manager::key()) !== null; RelationManager::canViewAny($record)mengizinkannya.
Item menggunakan $manager::key() sebagai key, $manager::title() sebagai label, dan $manager::icon() sebagai icon. Key berasal dari relation, bukan Page key, karena Page saat ini hanya dapat menyatakan relation apa yang sedang ditampilkan, bukan array key mana yang kebetulan digunakan di pages().
Authorization sengaja dilakukan melalui manager, bukan Resource. Apakah user dapat melihat posts milik sebuah record adalah jawaban policy posts tersebut, bukan jawaban policy record induknya. Lihat Relation pages.
Rule satu link
return count($items) > 1 ? $items : [];Satu link bukan navigation karena tidak ada tujuan lain untuk berpindah. Lone tab juga hanya menjadi noise pada setiap record Page. Karena itu Resource yang hanya memiliki view Page, atau user yang policy-nya tidak mengizinkan edit, mendapatkan list kosong daripada bar berisi satu item.
Gate::policy(User::class, ViewOnlyUserPolicy::class);
RecordSubNavigation::for(UserResource::class, $record, 'view'); // []2
3
Position
namespace PandaPanel\Enums;
enum SubNavigationPosition: string
{
case Top = 'top';
case Start = 'start';
case End = 'end';
}2
3
4
5
6
7
8
Top tampil sebagai tab strip; Start dan End tampil sebagai rail di sisi content. Tiga value mengikuti vocabulary Filament sehingga istilahnya dapat langsung dikenali.
Dideklarasikan pada Panel untuk seluruh Resource di dalamnya:
use PandaPanel\Enums\SubNavigationPosition;
$panel->subNavigationPosition(SubNavigationPosition::Start);
$panel->getSubNavigationPosition(); // SubNavigationPosition::Start2
3
4
Default Panel adalah SubNavigationPosition::Top.
Dapat di-override per Resource:
use PandaPanel\Enums\SubNavigationPosition;
use PandaPanel\Resources\Resource;
final class UserResource extends Resource
{
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::End;
}
UserResource::subNavigationPosition(); // SubNavigationPosition::End2
3
4
5
6
7
8
9
Nilai null pada Resource — default — berarti gunakan setting milik Panel.
Pada Resource Page
/** @return array{items: list<array<string, mixed>>, position: string} */
protected function subNavigation(?Model $record, string $currentPage): array;
protected function subNavigationPosition(): SubNavigationPosition;2
3
4
protected function subNavigation(?Model $record, string $currentPage): array
{
return [
'items' => $record === null
? []
: RecordSubNavigation::for(static::$resource, $record, $currentPage),
'position' => $this->subNavigationPosition()->value,
];
}2
3
4
5
6
7
8
9
Empat built-in record Page sudah memanggilnya dengan key yang benar. Custom record Page perlu memberikan key miliknya sendiri:
use Illuminate\Database\Eloquent\Model;
/**
* @return array<string, mixed>
*/
protected function pageMetadata(Model $record): array
{
return [
...$this->headingMetadata($record),
'breadcrumbs' => $this->serializeBreadcrumbs([/* … */]),
'headerActions' => [],
'scope' => static::renderHookScope(),
'cluster' => $this->clusterNavigation(),
'subNavigation' => $this->subNavigation($record, 'audit'),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Memberikan key yang tidak terdapat di list berarti tidak ada item active — 'audit' tidak cocok dengan item mana pun, sehingga strip tetap dirender tanpa highlight. Custom Page sendiri bukan sub-navigation item; lihat catatan di bawah.
subNavigationPosition() me-resolve declaration milik Resource terlebih dahulu, lalu fallback ke Panel:
return static::$resource::subNavigationPosition()
?? $this->panel()->getSubNavigationPosition();2
Data yang dikirim ke frontend
'subNavigation' => [
'position' => 'top',
'items' => [
['key' => 'view', 'label' => 'View', 'href' => '/admin/users/3', 'icon' => 'search', 'active' => true],
['key' => 'edit', 'label' => 'Edit', 'href' => '/admin/users/3/edit', 'icon' => 'settings', 'active' => false],
],
],2
3
4
5
6
7
export type SubNavigationPosition = 'top' | 'start' | 'end';
export interface SubNavigationItem {
key: string;
label: string;
href: string;
icon: string | null;
active: boolean;
}
export interface PageSubNavigation {
items: SubNavigationItem[];
position: SubNavigationPosition;
}2
3
4
5
6
7
8
9
10
11
12
13
14
Key tersebut tidak ada pada Page tanpa record — list Page, create Page, dan seluruh standalone Page. normalizePageMetadata() memperlakukannya sebagai { items: [], position: 'top' }, bukan shape error. Position di luar tiga case fallback ke top.
it('sends no sub-navigation on a page with no record', function (): void {
foreach (['/admin/users', '/admin/users/create'] as $url) {
$this->get($url)->assertInertia(fn (AssertableInertia $page) => $page->missing('page.subNavigation'));
}
});2
3
4
5
Rendering
resources/js/panel/components/PanelSubNavigation.vue hanya menerima items dan position:
<script setup lang="ts">
import PanelSubNavigation from '@/panel/components/PanelSubNavigation.vue';
import { usePanelPage } from '@/panel/composables/usePanelPage';
const page = usePanelPage();
</script>
<template>
<PanelSubNavigation
v-if="page && page.subNavigation.items.length > 0"
:items="page.subNavigation.items"
:position="page.subNavigation.position"
/>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
top merender bordered tab strip; start dan end merender stacked column. Yang berbeda hanya arah layout — daftar item dan active state selalu berasal dari server. Setiap link berupa Inertia <Link> yang membawa prefetch mode milik Panel, sehingga hover pada tab dapat memanaskan Page berikutnya. Lihat Prefetching.
Icon merupakan registry key yang di-resolve melalui resolveIcon(). Key yang tidak terdaftar tidak merender icon daripada menyebabkan failure. Lihat Icons.
Gotchas
- Daftar item dibatasi pada
view,edit, dan relation Page. Custom record Page tidak dapat bergabung secara otomatis: map diRecordSubNavigationhanya memiliki dua key fixed, sedangkan relation Page ditemukan melalui manager yang dirujuk. Custom Page tidak memiliki ability standar yang dapat ditanyakan map. Gunakan header action atau row action untuk menghubungkannya. - Page yang ditolak tidak menjadi disabled; item-nya tidak ada. Unauthorized item tidak pernah dirender, dan route melakukan enforcement yang sama secara independen. Menghapus item dari strip hanyalah convenience, bukan security control.
- Jika hanya tersisa satu link, bar tidak dirender sama sekali. Menolak
editpada Resource yang satu-satunya record Page lain adalahviewmenghasilkan strip kosong. - Label dan icon
viewsertaedittidak configurable. Keduanya berasal dariStr::headline($key)dan private constant. - Active flag ditentukan server. Nilainya
$key === $currentPage. Page yang mengirim key salah tidak men-highlight apa pun; tidak ada fallback client-side URL matching. hrefberasal dariResource::url($key, $record). Nested Resource me-resolve parent dari request saat ini, sehingga membangun strip di luar request untuk nested Resource membutuhkan parent yang sudah dibind.