fix(ui): update breadcrumb names after a rename

Closes #12341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-10-27 14:55:39 +01:00
1 parent f658d6df76
commit d3b1a3d405
23 files changed
+142 -12

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ class Show extends Component
$this->destination->name = $this->name;
$changedFields = auditChangedFields($this->destination);
$this->destination->save();
$this->dispatch('name-changed', uuid: $this->destination->uuid, name: $this->destination->name);
if ($changedFields !== []) {
auditLog('ui.destination.updated', $this->auditContext([
'changed_fields' => $changedFields,
@@ -973,6 +973,7 @@ class General extends Component
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
$this->dispatch('name-changed', uuid: $this->application->uuid, name: $this->application->name);
$dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, $this->application->team()->id);
$this->syncData();
if ($oldPortsExposes !== $this->application->ports_exposes || $oldCustomNetworkAliases !== $this->application->custom_network_aliases) {
@@ -135,6 +135,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -122,6 +122,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -126,6 +126,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -151,6 +151,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -143,6 +143,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -151,6 +151,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -167,6 +167,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -144,6 +144,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -81,6 +81,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
+1
View File
@@ -85,6 +85,7 @@ class Edit extends Component
'name' => $this->name,
'description' => $this->description,
]);
$this->dispatch('name-changed', uuid: $this->project->uuid, name: $this->project->name);
} else {
$this->name = $this->project->name;
$this->description = $this->project->description;
@@ -199,6 +199,7 @@ class StackForm extends Component
// Refresh and write files after a successful commit
$this->service->refresh();
$this->service->saveComposeConfigs();
$this->dispatch('name-changed', uuid: $this->service->uuid, name: $this->service->name);
$this->dispatch('refreshEnvs')->to(EditCompose::class);
$this->dispatch('refreshEnvs')->to(All::class);
+1
View File
@@ -282,6 +282,7 @@ class Change extends Component
$this->syncData(true);
$this->gitea_app->save();
$this->dispatch('success', 'Gitea App updated.');
$this->dispatch('name-changed', uuid: $this->gitea_app->uuid, name: $this->gitea_app->name);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1
View File
@@ -431,6 +431,7 @@ class Change extends Component
$this->syncData(true);
$this->github_app->save();
$this->isConnected = $this->github_app->isConnected();
$this->dispatch('name-changed', uuid: $this->github_app->uuid, name: $this->github_app->name);
$this->dispatch('success', 'Github App updated.');
} catch (ValidationException $e) {
throw $e;
+1
View File
@@ -282,6 +282,7 @@ class Change extends Component
$this->syncData(true);
$this->gitlab_app->save();
$this->dispatch('success', 'GitLab App updated.');
$this->dispatch('name-changed', uuid: $this->gitlab_app->uuid, name: $this->gitlab_app->name);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1
View File
@@ -195,6 +195,7 @@ class Form extends Component
// Update local property to reflect success
$this->isUsable = true;
});
$this->dispatch('name-changed', uuid: $this->storage->uuid, name: $this->storage->name);
$this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
+4 -1
View File
@@ -9,7 +9,10 @@ class SwitchTeam extends Component
{
public string $selectedTeamId = 'default';
protected $listeners = ['team-avatar-updated' => '$refresh'];
protected $listeners = [
'team-avatar-updated' => '$refresh',
'team-updated' => '$refresh',
];
public function mount()
{
+1
View File
@@ -147,6 +147,7 @@ class Index extends Component
$this->syncData(true);
$this->team->save();
refreshSession();
$this->dispatch('team-updated');
$this->dispatch('success', 'Team updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -1,4 +1,4 @@
@props(['label', 'items', 'title'])
@props(['label', 'items', 'title', 'uuid' => null])
<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"
@@ -8,7 +8,8 @@
@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">{{ $label }}</span>
<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" />
@@ -33,9 +34,10 @@
</div>
@foreach ($items as $item)
<a href="{{ $item['href'] }}" {{ wireNavigate() }} @click="open = false"
x-show="@js(strtolower($item['label'])).includes(search.toLowerCase())"
x-show="nameOf(@js($item['uuid'] ?? null), @js($item['label'])).toLowerCase().includes(search.toLowerCase())"
class="listbox-option {{ $item['active'] ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
<span class="min-w-0 flex-1 truncate">{{ $item['label'] }}</span>
<span class="min-w-0 flex-1 truncate"
x-text="nameOf(@js($item['uuid'] ?? null), @js($item['label']))">{{ $item['label'] }}</span>
</a>
@endforeach
</div>
@@ -39,6 +39,7 @@
->sortBy(fn ($item) => strtolower($item['resource']->name))
->map(fn ($item) => [
'label' => $item['resource']->name,
'uuid' => $item['resource']->uuid,
'href' => match ($item['type']) {
'application' => route('project.application.configuration', [
'project_uuid' => $currentProject->uuid,
@@ -107,6 +108,10 @@
<div class="flex min-w-0 items-center gap-0.5 text-[13px]" data-page-context="{{ $dashboardContext }}"
x-data="{
titleRepeats: false,
renamed: {},
nameOf(uuid, name) {
return this.renamed[uuid] ?? name;
},
syncTitle() {
const expected = this.$el.dataset.pageContext || '';
const heading = document.querySelector('.app-main h1');
@@ -116,7 +121,8 @@
this.titleRepeats = expected !== '' && title === expected;
},
}"
x-init="syncTitle()" x-on:livewire:navigated.window="$nextTick(() => syncTitle())">
x-init="syncTitle()" x-on:livewire:navigated.window="$nextTick(() => syncTitle())"
x-on:name-changed.window="renamed[$event.detail.uuid] = $event.detail.name">
{{-- Team --}}
<div class="shrink-0" x-data="{ collapsed: false }">
<livewire:switch-team />
@@ -129,8 +135,9 @@
@endif
@if ($currentStorage)
<x-breadcrumb-switcher title="S3 Storage" :label="$currentStorage->name" :items="$storages->map(fn ($storage) => [
<x-breadcrumb-switcher title="S3 Storage" :label="$currentStorage->name" :uuid="$currentStorage->uuid" :items="$storages->map(fn ($storage) => [
'label' => $storage->name,
'uuid' => $storage->uuid,
'href' => route('storage.show', ['storage_uuid' => $storage->uuid]),
'active' => $storage->uuid === $currentStorage->uuid,
])">
@@ -151,8 +158,9 @@
? filled($currentSource->installation_id)
: filled($currentSource->access_token);
@endphp
<x-breadcrumb-switcher title="Sources" :label="$currentSource->name ?: 'Source'" :items="$sources->map(fn ($source) => [
<x-breadcrumb-switcher title="Sources" :label="$currentSource->name ?: 'Source'" :uuid="$currentSource->uuid" :items="$sources->map(fn ($source) => [
'label' => $source->name ?: class_basename($source),
'uuid' => $source->uuid,
'href' => match (true) {
$source instanceof \App\Models\GithubApp => route('source.github.show', ['github_app_uuid' => $source->uuid]),
$source instanceof \App\Models\GiteaApp => route('source.gitea.show', ['gitea_app_uuid' => $source->uuid]),
@@ -174,8 +182,9 @@
@endif
@if ($currentDestination)
<x-breadcrumb-switcher title="Destinations" :label="$currentDestination->name" :items="$destinations->map(fn ($destination) => [
<x-breadcrumb-switcher title="Destinations" :label="$currentDestination->name" :uuid="$currentDestination->uuid" :items="$destinations->map(fn ($destination) => [
'label' => $destination->name,
'uuid' => $destination->uuid,
'href' => route('destination.show', ['destination_uuid' => $destination->uuid]),
'active' => $destination->getMorphClass() === $currentDestination->getMorphClass() && $destination->uuid === $currentDestination->uuid,
])">
@@ -210,7 +219,8 @@
<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">{{ $currentProject->name }}</span>
<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>
@@ -223,7 +233,7 @@
@foreach ($projects as $p)
<a href="{{ route($projectDestinationRoute, ['project_uuid' => $p->uuid]) }}" {{ wireNavigate() }} @click="open = false"
class="listbox-option {{ $p->uuid === $currentProject->uuid ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
<span class="min-w-0 flex-1 truncate">{{ $p->name }}</span>
<span class="min-w-0 flex-1 truncate" x-text="nameOf(@js($p->uuid), @js($p->name))">{{ $p->name }}</span>
</a>
@endforeach
</div>
@@ -257,7 +267,7 @@
@endif
@if ($currentResource)
<x-breadcrumb-switcher title="Resources" :label="$currentResource->name" :items="$resourceItems">
<x-breadcrumb-switcher title="Resources" :label="$currentResource->name" :uuid="$currentResource->uuid" :items="$resourceItems">
<x-slot:meta>
{{-- Below xl, the page heading shows the status. --}}
<div class="hidden xl:flex">
@@ -0,0 +1,60 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Team\Index as TeamIndex;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
});
test('renaming the team refreshes the team switcher', function () {
Livewire::test(TeamIndex::class)
->set('name', 'Renamed team')
->call('submit')
->assertDispatched('team-updated');
expect($this->team->fresh()->name)->toBe('Renamed team');
});
test('renaming an application sends the new name to the breadcrumb', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
$application = Application::factory()->create([
'name' => 'Old app',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'nginx',
'ports_exposes' => '80',
]);
Livewire::test(General::class, ['application' => $application->fresh()])
->set('name', 'New app')
->call('submit')
->assertHasNoErrors()
->assertDispatched('name-changed', uuid: $application->uuid, name: 'New app');
});
+37
View File
@@ -0,0 +1,37 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
$this->stack = seedBrowserResourceStack();
});
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')
->fill('name', 'Renamed Project');
submitLivewireForm($page);
$page->assertSeeIn('button[title="Switch project"]', 'Renamed Project')
->screenshot(filename: 'breadcrumb-project-renamed');
});
it('updates the resource breadcrumb after the database is renamed', function () {
$postgres = createBrowserPostgresql($this->stack, ['name' => 'Old Postgres']);
loginAndSkipBoarding();
$page = visit(databaseConfigurationUrl($this->stack['project'], $this->stack['environment'], $postgres));
$page->assertSeeIn('button[title="Switch resource"]', 'Old Postgres')
->fill('name', 'Renamed Postgres');
submitLivewireForm($page);
$page->assertSeeIn('button[title="Switch resource"]', 'Renamed Postgres')
->screenshot(filename: 'breadcrumb-database-renamed');
});