feat: Add strict hierarchical breadcrumbs option (#20153)

* Add strict hierarchical option to breadcrumbs method

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Update Page.php

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Update Page.php

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Update Page.php

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Add strict hierarchical breadcrumbs documentation

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Implement getClusterHierarchicalBreadcrumbs method

Add method to retrieve hierarchical breadcrumbs for clusters.

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Update Page.php

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Update Page.php

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>

* Add strict hierarchical breadcrumb coverage

* Improve navigation hierarchy breadcrumbs

* Refine custom page breadcrumb defaults

* Support clustered resource record breadcrumbs

---------

Signed-off-by: ahmed-rashad-alnaggar <131385452+ahmed-rashad-alnaggar@users.noreply.github.com>
Co-authored-by: Dan Harrin <git@danharrin.com>
This commit is contained in:
ahmed-rashad-alnaggar
2026-09-26 16:07:21 +01:00
committed by GitHub
co-authored by Dan Harrin
parent 5f1a0a6341
commit b986eee58f
7 changed files with 602 additions and 7 deletions
+34
View File
@@ -639,6 +639,40 @@ public function panel(Panel $panel): Panel
}
```
## Adding a breadcrumb to a custom page
Custom pages do not include a breadcrumb for the current page by default. You may add one using the `$breadcrumb` property:
```php
use Filament\Pages\Page;
class Settings extends Page
{
protected static ?string $breadcrumb = 'Settings';
// ...
}
```
## Including the navigation hierarchy in breadcrumbs
By default, breadcrumbs for a page are built from its resource and parent record relationships. For example, an `EditRecord` page's breadcrumbs are derived from its resource's list and view pages, and any parent resources in the case of nested resources.
If you would also like breadcrumbs to reflect the full navigation hierarchy of a page, including its [cluster](clusters), [navigation group](#grouping-navigation-items), and [navigation parent item](#grouping-navigation-items-under-other-items), you can enable this behavior in your [configuration](../panel-configuration):
```php
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->breadcrumbs(hasNavigationHierarchy: true);
}
```
When enabled, the navigation hierarchy is prepended to the page's existing breadcrumbs. If a custom page does not have a `$breadcrumb`, its title is used as the current breadcrumb when its navigation hierarchy can be resolved. If the page is not present in the navigation, its existing breadcrumbs are used instead.
## Reloading the sidebar and topbar
Once a page in the panel is loaded, the sidebar and topbar are not reloaded until you navigate away from the page, or until a menu item is clicked to trigger an action. You can manually reload these components to update them by dispatching a `refresh-sidebar` or `refresh-topbar` browser event.
+166 -4
View File
@@ -9,6 +9,7 @@ use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Clusters\Cluster;
use Filament\Facades\Filament;
use Filament\Navigation\NavigationGroup;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\Schemas\Components\Component;
@@ -45,6 +46,8 @@ abstract class Page extends BasePage
*/
protected static ?string $cluster = null;
protected static ?string $breadcrumb = null;
protected static bool $isDiscovered = true;
/**
@@ -179,16 +182,175 @@ abstract class Page extends BasePage
return $panel->generateRouteName($routeName);
}
public function getBreadcrumb(): ?string
{
return static::$breadcrumb;
}
/**
* @return array<string>
* @return array<string | Htmlable>
*/
public function getBreadcrumbs(): array
{
if (filled($cluster = static::getCluster())) {
return $cluster::unshiftClusterBreadcrumbs([]);
$breadcrumb = $this->getBreadcrumb();
if (Filament::getCurrentOrDefaultPanel()->hasNavigationHierarchyInBreadcrumbs()) {
$navigationHierarchyBreadcrumbs = $this->getNavigationHierarchyBreadcrumbs();
if ($navigationHierarchyBreadcrumbs !== null) {
return [
...$navigationHierarchyBreadcrumbs,
$breadcrumb ?? $this->getTitle(),
];
}
}
return [];
$breadcrumbs = filled($cluster = static::getCluster())
? $cluster::unshiftClusterBreadcrumbs([])
: [];
if ($breadcrumb !== null) {
$breadcrumbs[] = $breadcrumb;
}
return $breadcrumbs;
}
/**
* @return array<string> | null
*/
protected function getNavigationHierarchyBreadcrumbs(): ?array
{
if (filled($cluster = static::getCluster())) {
$clusterBreadcrumbs = $this->getNavigationBreadcrumbs(
$cluster,
static fn (): string => $cluster::getNavigationUrl(),
);
$pageBreadcrumbs = $this->getNavigationBreadcrumbs(
$this->getSubNavigationBreadcrumbItemKey(),
fn (): ?string => $this->getSubNavigationBreadcrumbItemUrl(),
$this->getCachedSubNavigation(),
);
if (($clusterBreadcrumbs === null) || ($pageBreadcrumbs === null)) {
return null;
}
return [
...$clusterBreadcrumbs,
$cluster::getUrl() => $cluster::getClusterBreadcrumb(),
...$pageBreadcrumbs,
];
}
return $this->getNavigationBreadcrumbs(
$this->getNavigationBreadcrumbItemKey(),
fn (): ?string => $this->getNavigationBreadcrumbItemUrl(),
);
}
protected function getNavigationBreadcrumbItemKey(): string
{
return static::class;
}
protected function getNavigationBreadcrumbItemUrl(): ?string
{
if (! static::shouldRegisterNavigation()) {
return null;
}
return static::getNavigationUrl();
}
protected function getSubNavigationBreadcrumbItemKey(): string
{
return $this->getNavigationBreadcrumbItemKey();
}
protected function getSubNavigationBreadcrumbItemUrl(): ?string
{
return $this->getNavigationBreadcrumbItemUrl();
}
/**
* @param Closure(): ?string $navigationItemUrl
* @param ?array<NavigationGroup> $navigation
* @return array<string> | null
*/
protected function getNavigationBreadcrumbs(string $navigationItemKey, Closure $navigationItemUrl, ?array $navigation = null): ?array
{
if ($navigation === null) {
$panel = Filament::getCurrentOrDefaultPanel();
if (! $panel->hasNavigation()) {
return null;
}
$navigation = $panel->getNavigation();
}
foreach ($navigation as $navigationGroup) {
$breadcrumbs = $this->getNavigationItemBreadcrumbs(
$navigationItemKey,
$navigationItemUrl,
$navigationGroup->getItems(),
);
if ($breadcrumbs === null) {
continue;
}
if (filled($navigationGroupLabel = $navigationGroup->getLabel())) {
$breadcrumbs = [
$navigationGroupLabel,
...$breadcrumbs,
];
}
return $breadcrumbs;
}
return null;
}
/**
* @param Closure(): ?string $navigationItemUrl
* @param iterable<NavigationItem> $navigationItems
* @return array<string> | null
*/
protected function getNavigationItemBreadcrumbs(string $navigationItemKey, Closure $navigationItemUrl, iterable $navigationItems): ?array
{
foreach ($navigationItems as $navigationItem) {
if ($navigationItem->getKey() === $navigationItemKey) {
$resolvedNavigationItemUrl = $navigationItemUrl();
if (filled($resolvedNavigationItemUrl) && ($navigationItem->getUrl() === $resolvedNavigationItemUrl)) {
return [];
}
}
$breadcrumbs = $this->getNavigationItemBreadcrumbs(
$navigationItemKey,
$navigationItemUrl,
$navigationItem->getChildItems(),
);
if ($breadcrumbs === null) {
continue;
}
$navigationItemUrl = $navigationItem->getUrl();
return [
...(filled($navigationItemUrl)
? [$navigationItemUrl => $navigationItem->getLabel()]
: [$navigationItem->getLabel()]),
...$breadcrumbs,
];
}
return null;
}
public static function getNavigationGroup(): string | UnitEnum | null
@@ -8,9 +8,12 @@ trait HasBreadcrumbs
{
protected bool | Closure $hasBreadcrumbs = true;
public function breadcrumbs(bool | Closure $condition = true): static
protected bool | Closure $hasNavigationHierarchyInBreadcrumbs = false;
public function breadcrumbs(bool | Closure $condition = true, bool | Closure $hasNavigationHierarchy = false): static
{
$this->hasBreadcrumbs = $condition;
$this->hasNavigationHierarchyInBreadcrumbs = $hasNavigationHierarchy;
return $this;
}
@@ -19,4 +22,9 @@ trait HasBreadcrumbs
{
return (bool) $this->evaluate($this->hasBreadcrumbs);
}
public function hasNavigationHierarchyInBreadcrumbs(): bool
{
return (bool) $this->evaluate($this->hasNavigationHierarchyInBreadcrumbs);
}
}
+47 -2
View File
@@ -40,8 +40,6 @@ abstract class Page extends BasePage
use CanAuthorizeResourceAccess;
use InteractsWithParentRecord;
protected static ?string $breadcrumb = null;
protected static string $resource;
protected static bool $isDiscovered = false;
@@ -227,6 +225,17 @@ abstract class Page extends BasePage
}
}
if (Filament::getCurrentOrDefaultPanel()->hasNavigationHierarchyInBreadcrumbs()) {
$navigationHierarchyBreadcrumbs = $this->getNavigationHierarchyBreadcrumbs();
if ($navigationHierarchyBreadcrumbs !== null) {
return [
...$navigationHierarchyBreadcrumbs,
...$breadcrumbs,
];
}
}
if (filled($cluster = static::getCluster())) {
return $cluster::unshiftClusterBreadcrumbs($breadcrumbs);
}
@@ -245,6 +254,42 @@ abstract class Page extends BasePage
];
}
protected function getNavigationBreadcrumbItemKey(): string
{
return static::getResource();
}
protected function getNavigationBreadcrumbItemUrl(): ?string
{
$resource = static::getResource();
if ((! $resource::shouldRegisterNavigation()) || $resource::getParentResourceRegistration()) {
return null;
}
return $resource::getNavigationUrl();
}
protected function getSubNavigationBreadcrumbItemKey(): string
{
return $this->getSubNavigationParameters() ? static::class : parent::getSubNavigationBreadcrumbItemKey();
}
protected function getSubNavigationBreadcrumbItemUrl(): ?string
{
$parameters = $this->getSubNavigationParameters();
if (! $parameters) {
return parent::getSubNavigationBreadcrumbItemUrl();
}
if (! static::shouldRegisterNavigation($parameters)) {
return null;
}
return static::getNavigationUrl($parameters);
}
/**
* @param array<string, mixed> $parameters
*/
@@ -4,10 +4,30 @@ use Filament\Facades\Filament;
use Filament\Navigation\NavigationItem;
use Filament\Tests\Fixtures\Pages\ConfigurableSettings;
use Filament\Tests\Fixtures\Resources\Posts\ConfigurablePostResource;
use Filament\Tests\Fixtures\Resources\Posts\Pages\ListConfigurablePosts;
use Filament\Tests\Panels\Configuration\TestCase;
uses(TestCase::class);
it('includes the navigation hierarchy in breadcrumbs for the active page and resource configurations', function (): void {
Filament::getCurrentOrDefaultPanel()->breadcrumbs(hasNavigationHierarchy: true);
Filament::forPageConfiguration(ConfigurableSettings::class, 'general');
expect(app(ConfigurableSettings::class)->getBreadcrumbs())->toBe([
'Settings',
'Configurable Settings',
]);
Filament::setCurrentPageConfigurationKey(null);
Filament::forResourceConfiguration(ConfigurablePostResource::class, 'archived');
expect(app(ListConfigurablePosts::class)->getResourceBreadcrumbs())->toBe([
'Archive',
ConfigurablePostResource::getUrl() => 'Posts',
]);
});
describe('resource configuration navigation', function (): void {
it('registers separate navigation items for each resource configuration', function (): void {
$navigation = Filament::getNavigation();
+309
View File
@@ -0,0 +1,309 @@
<?php
use Filament\Facades\Filament;
use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationGroup;
use Filament\Navigation\NavigationItem;
use Filament\Pages\Page;
use Filament\Resources\Pages\Page as ResourcePage;
use Filament\Support\Icons\Heroicon;
use Filament\Tests\Fixtures\Clusters\UserManagement;
use Filament\Tests\Fixtures\Clusters\UserManagement\Pages\ManageAdmins;
use Filament\Tests\Fixtures\Clusters\WithoutSubNavigationCluster;
use Filament\Tests\Fixtures\Clusters\WithoutSubNavigationCluster\Pages\ClusteredPageWithoutSubNavigation;
use Filament\Tests\Fixtures\Pages\Settings;
use Filament\Tests\Fixtures\Resources\Posts\Pages\ListPosts;
use Filament\Tests\Fixtures\Resources\Posts\PostResource;
use Filament\Tests\Panels\Pages\TestCase;
use function Filament\Tests\livewire;
uses(TestCase::class);
afterEach(function (): void {
PostResource::navigationGroup('Blog');
PostResource::navigationParentItem(null);
UserManagement::navigationGroup(null);
UserManagement::navigationParentItem(null);
WithoutSubNavigationCluster::navigationGroup(null);
});
it('can configure the navigation hierarchy in breadcrumbs with a `Closure`', function (): void {
$panel = Filament::getCurrentOrDefaultPanel()
->breadcrumbs(false, hasNavigationHierarchy: fn (): bool => true);
expect($panel->hasBreadcrumbs())->toBeFalse()
->and($panel->hasNavigationHierarchyInBreadcrumbs())->toBeTrue();
});
it('preserves existing page and resource breadcrumbs when the navigation hierarchy is not included', function (): void {
expect(app(Settings::class)->getBreadcrumbs())->toBe([])
->and(app(ListPosts::class)->getResourceBreadcrumbs())->toBe([
PostResource::getUrl() => 'Posts',
]);
});
it('includes an explicitly configured custom page breadcrumb without the navigation hierarchy', function (): void {
expect(app(PageWithBreadcrumb::class)->getBreadcrumbs())->toBe([
'Custom breadcrumb',
]);
});
it('preserves the title fallback for resource page breadcrumbs', function (): void {
expect(app(ResourcePageWithTitle::class)->getBreadcrumb())->toBe('Resource page title');
});
it('includes the navigation hierarchy in breadcrumbs for a grouped page with a parent item', function (): void {
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigationItems([
NavigationItem::make('Administration home')
->group('Administration')
->icon(Heroicon::OutlinedHome)
->url('/administration'),
...GroupedBreadcrumbsPage::getNavigationItems(),
]);
expect(app(GroupedBreadcrumbsPage::class)->getBreadcrumbs())->toBe([
'Administration',
'/administration' => 'Administration home',
'Reports',
]);
livewire(GroupedBreadcrumbsPage::class)
->assertSuccessful();
});
it('includes the navigation hierarchy in breadcrumbs for a grouped resource page with a parent item', function (): void {
PostResource::navigationParentItem('Blog home');
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigationItems([
NavigationItem::make('Blog home')
->group('Blog')
->icon(Heroicon::OutlinedHome)
->url('/blog'),
]);
expect(app(ListPosts::class)->getResourceBreadcrumbs())->toBe([
'Blog',
'/blog' => 'Blog home',
PostResource::getUrl() => 'Posts',
]);
});
it('renders the navigation hierarchy in breadcrumbs', function (): void {
PostResource::navigationParentItem('Blog home');
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigationItems([
NavigationItem::make('Blog home')
->group('Blog')
->icon(Heroicon::OutlinedHome)
->url('/blog'),
]);
visit(PostResource::getUrl())
->assertScript(<<<'JS'
Array.from(document.querySelectorAll('.fi-breadcrumbs-item-label')).map((item) => [
item.textContent.trim(),
item.href ? new URL(item.href).pathname : null,
])
JS, [
['Blog', null],
['Blog home', '/blog'],
['Posts', '/posts'],
['List', null],
])
->assertNoAccessibilityIssues();
visit(PostResource::getUrl())
->inDarkMode()
->assertNoAccessibilityIssues();
});
it('includes the navigation hierarchy in breadcrumbs for clustered pages', function (): void {
UserManagement::navigationGroup('Administration');
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true);
expect(app(ManageAdmins::class)->getBreadcrumbs())->toBe([
'Administration',
UserManagement::getUrl() => 'User Management',
'User Management',
'Manage Admins',
]);
});
it('includes the navigation hierarchy in breadcrumbs for clustered resource record pages with sub-navigation', function (): void {
UserManagement::navigationGroup('Administration');
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true);
expect(app(ClusteredResourceRecordPage::class)->getBreadcrumbs())->toBe([
'Administration',
UserManagement::getUrl() => 'User Management',
'Record',
PostResource::getUrl() => 'Posts',
'Edit post',
]);
});
it('falls back to cluster breadcrumbs when cluster sub-navigation is disabled', function (): void {
WithoutSubNavigationCluster::navigationGroup('Administration');
$breadcrumbs = app(ClusteredPageWithoutSubNavigation::class)->getBreadcrumbs();
Filament::getCurrentOrDefaultPanel()->breadcrumbs(hasNavigationHierarchy: true);
expect(app(ClusteredPageWithoutSubNavigation::class)->getBreadcrumbs())->toBe($breadcrumbs);
$this->get(ClusteredPageWithoutSubNavigation::getUrl())
->assertSuccessful();
});
it('falls back to cluster breadcrumbs when a custom `NavigationBuilder` omits the cluster', function (): void {
$breadcrumbs = app(ManageAdmins::class)->getBreadcrumbs();
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigation(fn (NavigationBuilder $navigation): NavigationBuilder => $navigation);
expect(app(ManageAdmins::class)->getBreadcrumbs())->toBe($breadcrumbs);
});
it('falls back to existing breadcrumbs when a custom `NavigationBuilder` omits the page hierarchy', function (): void {
$breadcrumbs = app(GroupedBreadcrumbsPage::class)->getBreadcrumbs();
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigation(fn (NavigationBuilder $navigation): NavigationBuilder => $navigation);
expect(app(GroupedBreadcrumbsPage::class)->getBreadcrumbs())->toBe($breadcrumbs);
});
it('includes the navigation hierarchy in breadcrumbs from a custom `NavigationBuilder`', function (): void {
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigation(fn (NavigationBuilder $navigation): NavigationBuilder => $navigation
->group(NavigationGroup::make('Administration')
->items(GroupedBreadcrumbsPage::getNavigationItems())));
expect(app(GroupedBreadcrumbsPage::class)->getBreadcrumbs())->toBe([
'Administration',
'Reports',
]);
});
it('falls back to existing breadcrumbs for a grouped page that does not register navigation', function (): void {
$breadcrumbs = app(GroupedUnregisteredBreadcrumbsPage::class)->getBreadcrumbs();
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigationItems([
NavigationItem::make('Published reports')
->key(GroupedUnregisteredBreadcrumbsPage::class)
->group('Administration')
->icon(Heroicon::OutlinedDocumentText)
->childItems([
NavigationItem::make('Published report')
->url('/published-reports'),
]),
]);
expect(app(GroupedUnregisteredBreadcrumbsPage::class)->getBreadcrumbs())->toBe($breadcrumbs);
});
it('falls back to existing breadcrumbs when navigation is disabled', function (): void {
$breadcrumbs = app(GroupedBreadcrumbsPage::class)->getBreadcrumbs();
Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true)
->navigationItems([
NavigationItem::make('Administration home')
->group('Administration')
->icon(Heroicon::OutlinedHome)
->url('/administration'),
...GroupedBreadcrumbsPage::getNavigationItems(),
])
->navigation(false);
expect(app(GroupedBreadcrumbsPage::class)->getBreadcrumbs())->toBe($breadcrumbs);
});
class GroupedBreadcrumbsPage extends Page
{
protected static string | UnitEnum | null $navigationGroup = 'Administration';
protected static ?string $navigationParentItem = 'Administration home';
protected static ?string $title = 'Reports';
protected string $view = 'filament-panels::pages.page';
public static function getNavigationUrl(): string
{
return '/reports';
}
}
class PageWithBreadcrumb extends Page
{
protected static ?string $breadcrumb = 'Custom breadcrumb';
protected string $view = 'filament-panels::pages.page';
}
class ResourcePageWithTitle extends ResourcePage
{
protected static string $resource = PostResource::class;
protected static ?string $title = 'Resource page title';
}
class ClusteredResourceRecordPage extends ResourcePage
{
protected static string $resource = PostResource::class;
protected static ?string $title = 'Edit post';
public static function getCluster(): ?string
{
return UserManagement::class;
}
public function getSubNavigation(): array
{
return [
NavigationItem::make('Edit post')
->key(static::class)
->group('Record')
->url('/posts/1/edit'),
];
}
public function getSubNavigationParameters(): array
{
return ['record' => 1];
}
public static function getNavigationUrl(array $parameters = []): string
{
return "/posts/{$parameters['record']}/edit";
}
}
class GroupedUnregisteredBreadcrumbsPage extends Page
{
protected static string | UnitEnum | null $navigationGroup = 'Administration';
protected static bool $shouldRegisterNavigation = false;
protected static ?string $title = 'Archived reports';
protected string $view = 'filament-panels::pages.page';
}
@@ -2,6 +2,7 @@
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Facades\Filament;
use Filament\Tests\Fixtures\Models\Company;
use Filament\Tests\Fixtures\Models\Post;
use Filament\Tests\Fixtures\Models\Team;
@@ -25,6 +26,22 @@ use function Pest\Laravel\assertSoftDeleted;
uses(TestCase::class);
describe('soft-deletable nested resource', function (): void {
it('can render with the navigation hierarchy in breadcrumbs regardless of panel navigation visibility', function (): void {
$parentRecord = User::factory()->create();
$url = UserPostResource::getUrl('index', [
'author' => $parentRecord,
]);
$panel = Filament::getCurrentOrDefaultPanel()
->breadcrumbs(hasNavigationHierarchy: true);
$this->get($url)->assertSuccessful();
$panel->navigation(false);
$this->get($url)->assertSuccessful();
});
it('can render list page', function (): void {
$parentRecord = User::factory()->create();