fix(breadcrumb): link breadcrumb names to their page

The section and resource names in the top breadcrumb open their page.
The arrow next to a name still opens the switch list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-09 17:49:07 +01:00
co-authored by Claude Opus 5.5
parent dc7a5946e9
commit b81cc4116d
5 changed files with 92 additions and 62 deletions
+8
View File
@@ -0,0 +1,8 @@
---
type: changed
---
Names in the top breadcrumb, which now link to their page
- The section name, such as `Servers`, opens the list page of the section
- The project, environment, resource, server, storage, source, destination, and tag names open their own page
- The arrow next to a name still opens the switch list
@@ -1,20 +1,28 @@
@props(['label', 'items', 'title', 'uuid' => null])
@php
$href = collect($items)->firstWhere('active', true)['href'] ?? null;
@endphp
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
<div class="relative min-w-0 shrink" x-data="{ open: false, search: '' }" @keydown.escape.window="open = false"
@click.outside="open = false">
<div class="flex h-8 min-w-0 items-center gap-1">
<button type="button"
@click="open = !open; if (open) { search = ''; $nextTick(() => $refs.search.focus()) }"
title="Switch resource"
class="flex h-8 min-w-0 items-center gap-1.5 rounded-md px-2 opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg"
x-text="nameOf(@js($uuid), @js($label))">{{ $label }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="flex h-8 min-w-0 items-center rounded-md opacity-70 transition-opacity hover:opacity-100">
<a href="{{ $href }}" {{ wireNavigate() }} title="Go to {{ strtolower(str($title)->singular()) }}"
class="flex h-8 min-w-0 items-center rounded-l-md pr-1 pl-2 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg"
x-text="nameOf(@js($uuid), @js($label))">{{ $label }}</span>
</a>
<button type="button"
@click="open = !open; if (open) { search = ''; $nextTick(() => $refs.search.focus()) }"
title="Switch resource" class="flex h-8 shrink-0 items-center rounded-r-md px-0.5 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
@isset($meta)
{{ $meta }}
@endisset
@@ -85,24 +85,24 @@
? \App\Models\Tag::ownedByCurrentTeam()->orderBy('name')->get()->unique('name')->values()
: collect();
$currentTag = $tags->firstWhere('name', $tagName);
$dashboardContext = match (true) {
request()->routeIs('dashboard') => 'Dashboard',
request()->routeIs('project.index', 'project.canvas.index') => 'Projects',
request()->routeIs('terminal') => 'Terminal',
request()->routeIs('server.*') => 'Servers',
request()->routeIs('source.*') => 'Sources',
request()->routeIs('destination.*') => 'Destinations',
request()->routeIs('registries.*') => 'Registries',
request()->routeIs('storage.*') => 'Storage',
request()->routeIs('shared-variables.*') => 'Shared Variables',
request()->routeIs('team.*') => 'Team',
request()->routeIs('notifications.*') => 'Notifications',
request()->routeIs('security.*') => 'Keys & Tokens',
request()->routeIs('tags.*') => 'Tags',
request()->routeIs('settings.*') => 'Settings',
request()->routeIs('profile*') => 'Profile',
request()->routeIs('admin.*') => 'Admin',
default => null,
[$dashboardContext, $dashboardContextRoute] = match (true) {
request()->routeIs('dashboard') => ['Dashboard', 'dashboard'],
request()->routeIs('project.index', 'project.canvas.index') => ['Projects', 'project.index'],
request()->routeIs('terminal') => ['Terminal', 'terminal'],
request()->routeIs('server.*') => ['Servers', 'server.index'],
request()->routeIs('source.*') => ['Sources', 'source.all'],
request()->routeIs('destination.*') => ['Destinations', 'destination.index'],
request()->routeIs('registries.*') => ['Registries', 'registries.index'],
request()->routeIs('storage.*') => ['Storage', 'storage.index'],
request()->routeIs('shared-variables.*') => ['Shared Variables', 'shared-variables.index'],
request()->routeIs('team.*') => ['Team', 'team.index'],
request()->routeIs('notifications.*') => ['Notifications', 'notifications.email'],
request()->routeIs('security.*') => ['Keys & Tokens', 'security.private-key.index'],
request()->routeIs('tags.*') => ['Tags', 'tags.show'],
request()->routeIs('settings.*') => ['Settings', 'settings.index'],
request()->routeIs('profile*') => ['Profile', 'profile'],
request()->routeIs('admin.*') => ['Admin', 'admin.index'],
default => [null, null],
};
@endphp
<div class="flex min-w-0 items-center gap-0.5 text-[13px]" data-page-context="{{ $dashboardContext }}"
@@ -130,8 +130,8 @@
@if (!$currentProject && $dashboardContext)
<span x-show="!titleRepeats" x-cloak class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
<span x-show="!titleRepeats" x-cloak
class="flex h-8 min-w-0 items-center truncate px-2 font-semibold text-black opacity-70 dark:text-fg">{{ $dashboardContext }}</span>
<a href="{{ route($dashboardContextRoute) }}" {{ wireNavigate() }} x-show="!titleRepeats" x-cloak
class="flex h-8 min-w-0 items-center truncate rounded-md px-2 font-semibold text-black opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:text-fg dark:hover:bg-white/[0.05]">{{ $dashboardContext }}</a>
@endif
@if ($currentStorage)
@@ -217,14 +217,19 @@
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
{{-- Project switcher --}}
<div class="relative min-w-0 shrink" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch project"
class="flex items-center gap-1.5 min-w-0 max-w-full h-8 px-2 rounded-md opacity-70 transition-[background-color,opacity] hover:opacity-100 hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg"
x-text="nameOf(@js($currentProject->uuid), @js($currentProject->name))">{{ $currentProject->name }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="flex h-8 min-w-0 items-center rounded-md opacity-70 transition-opacity hover:opacity-100">
<a href="{{ route($projectDestinationRoute, ['project_uuid' => $currentProject->uuid]) }}" {{ wireNavigate() }} title="Go to project"
class="flex h-8 min-w-0 items-center rounded-l-md pr-1 pl-2 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg"
x-text="nameOf(@js($currentProject->uuid), @js($currentProject->name))">{{ $currentProject->name }}</span>
</a>
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch project"
class="flex h-8 shrink-0 items-center rounded-r-md px-0.5 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-56 max-w-72">
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
@@ -244,13 +249,18 @@
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
{{-- Environment switcher --}}
<div class="relative min-w-0 shrink" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch environment"
class="flex items-center gap-1.5 min-w-0 max-w-full h-8 px-2 rounded-md opacity-70 transition-[background-color,opacity] hover:opacity-100 hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentEnvironment->name }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="flex h-8 min-w-0 items-center rounded-md opacity-70 transition-opacity hover:opacity-100">
<a href="{{ route('project.resource.index', ['project_uuid' => $currentProject->uuid, 'environment_uuid' => $currentEnvironment->uuid]) }}" {{ wireNavigate() }} title="Go to environment"
class="flex h-8 min-w-0 items-center rounded-l-md pr-1 pl-2 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentEnvironment->name }}</span>
</a>
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch environment"
class="flex h-8 shrink-0 items-center rounded-r-md px-0.5 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-52 max-w-72">
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
@@ -118,18 +118,22 @@
<span class="relative flex min-w-0 shrink items-center gap-2 px-1"
x-data="{ open: false, search: '', servers: @js($serverSwitcherOptions) }"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button"
class="flex h-8 max-w-56 min-w-0 items-center gap-1.5 rounded-md px-2 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05] xl:max-w-72"
@click="open = !open" :aria-expanded="open" aria-label="Switch server">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">
{{ $server->name }}
</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24"
fill="none" aria-hidden="true">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="flex h-8 max-w-56 min-w-0 items-center rounded-md xl:max-w-72">
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }} title="Go to server"
class="flex h-8 min-w-0 items-center rounded-l-md pr-1 pl-2 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">
{{ $server->name }}
</span>
</a>
<button type="button" class="flex h-8 shrink-0 items-center rounded-r-md px-0.5 transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]"
@click="open = !open" :aria-expanded="open" aria-label="Switch server">
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24"
fill="none" aria-hidden="true">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div x-cloak x-show="open" x-transition.origin.top.left
class="listbox-panel top-9! left-1! z-[90]! w-64! min-w-0!">
<div class="border-b border-neutral-200 p-1.5 dark:border-white/[0.08]">
+4 -4
View File
@@ -13,12 +13,12 @@ it('updates the project breadcrumb after the project is renamed', function () {
loginAndSkipBoarding();
$page = visit(route('project.edit', ['project_uuid' => $this->stack['project']->uuid]));
$page->assertSeeIn('button[title="Switch project"]', 'Browser Project')
$page->assertSeeIn('a[title="Go to project"]', 'Browser Project')
->fill('name', 'Renamed Project');
submitLivewireForm($page);
$page->assertSeeIn('button[title="Switch project"]', 'Renamed Project')
$page->assertSeeIn('a[title="Go to project"]', 'Renamed Project')
->screenshot(filename: 'breadcrumb-project-renamed');
});
@@ -27,11 +27,11 @@ it('updates the resource breadcrumb after the database is renamed', function ()
loginAndSkipBoarding();
$page = visit(databaseConfigurationUrl($this->stack['project'], $this->stack['environment'], $postgres));
$page->assertSeeIn('button[title="Switch resource"]', 'Old Postgres')
$page->assertSeeIn('a[title="Go to resource"]', 'Old Postgres')
->fill('name', 'Renamed Postgres');
submitLivewireForm($page);
$page->assertSeeIn('button[title="Switch resource"]', 'Renamed Postgres')
$page->assertSeeIn('a[title="Go to resource"]', 'Renamed Postgres')
->screenshot(filename: 'breadcrumb-database-renamed');
});