fix(ui): load the Compose file on open and show a disabled Deploy until it loads

Load a missing Compose file once when an application page opens. Until it
loads, show Load compose next to a disabled Deploy button on every page.
Keep teleported tooltips keyed so Livewire morphs the form after updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 22:11:48 +02:00
co-authored by Claude Opus 5.5
parent 94e70983f4
commit d30f582bfe
7 changed files with 201 additions and 21 deletions
+30 -12
View File
@@ -155,6 +155,7 @@ class General extends Component
'resetDefaultLabels',
'configurationChanged' => '$refresh',
'confirmDomainUsage',
'composeFileLoaded' => 'syncLoadedComposeFile',
];
protected function rules(): array
@@ -575,18 +576,7 @@ class General extends Component
return;
}
$this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application);
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
$this->application->refresh();
// Sync the docker_compose_raw from the model to the component property
// This ensures the Monaco editor displays the loaded compose file
$this->syncData();
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
$this->showLoadedComposeFile();
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded');
@@ -605,6 +595,34 @@ class General extends Component
}
}
/**
* Shows a Compose file that the heading loaded while this form is open.
*/
public function syncLoadedComposeFile(): void
{
$this->authorize('update', $this->application);
$this->application->refresh();
$this->parsedServices = $this->application->parse();
$this->showLoadedComposeFile();
}
private function showLoadedComposeFile(): void
{
$this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application);
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
$this->application->refresh();
// Sync the docker_compose_raw from the model to the component property
// This ensures the Monaco editor displays the loaded compose file
$this->syncData();
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
}
public function generateDomain(string $serviceName)
{
try {
+45 -1
View File
@@ -4,10 +4,13 @@ namespace App\Livewire\Project\Application;
use App\Actions\Application\StopApplication;
use App\Actions\Docker\GetContainersStatus;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Traits\ListensToTeamChannel;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Heading extends Component
@@ -29,10 +32,18 @@ class Heading extends Component
public bool $docker_cleanup = true;
/**
* Set once on page open, so that a later switch to the Compose build pack does not load the file
* before the user confirms its location.
*/
#[Locked]
public bool $loadComposeOnOpen = false;
public function getListeners()
{
return [
'compose_loaded' => '$refresh',
'configurationChanged' => '$refresh',
'update_links' => '$refresh',
...$this->teamChannelListeners([
'ServiceStatusChanged' => 'checkStatus',
@@ -53,6 +64,39 @@ class Heading extends Component
$lastDeployment = $this->application->get_last_successful_deployment();
$this->lastDeploymentInfo = data_get_str($lastDeployment, 'commit')->limit(7).' '.data_get($lastDeployment, 'commit_message');
$this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit'));
$this->loadComposeOnOpen = $this->needsComposeFile() && auth()->user()?->can('update', $this->application);
}
public function needsComposeFile(): bool
{
return $this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw);
}
public function loadComposeFile(bool $isInit = false)
{
try {
$this->authorize('update', $this->application);
if ($isInit && ! $this->needsComposeFile()) {
return;
}
$loaded = $this->application->loadComposeFile();
if (is_null(data_get($loaded, 'parsedServices'))) {
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
return;
}
$this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('composeFileLoaded')->to(General::class);
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshEnvs')->to(All::class);
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->application->refresh();
}
}
/**
@@ -126,7 +170,7 @@ class Heading extends Component
try {
$this->authorize('deploy', $this->application);
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
if ($this->needsComposeFile()) {
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
return;
@@ -81,7 +81,7 @@
@if ($authDisabled || filled($tooltip))
{{-- teleported so a dropdown or other stacking context cannot hide or move it --}}
<template x-teleport="body">
<div x-ref="tip" x-show="visible" x-cloak :id="$id('button-tooltip')" role="tooltip"
<div x-ref="tip" x-show="visible" x-cloak :id="$id('button-tooltip')" wire:key="button-tooltip" role="tooltip"
x-transition:enter="transition-opacity ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-75"
+1 -1
View File
@@ -116,7 +116,7 @@
</button>
{{-- teleported so a dropdown or other stacking context cannot put it under the sidebar --}}
<template x-teleport="body">
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" role="tooltip"
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" wire:key="helper-popup" role="tooltip"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 translate-y-0.5"
x-transition:enter-end="opacity-100 translate-y-0"
@@ -11,11 +11,11 @@
targets="name,description,buildPack,staticImage,baseDirectory,dockerComposeLocation,dockerComposeCustomBuildCommand,dockerComposeCustomStartCommand,watchPaths,dockerfileLocation,dockerfileTargetBuild,publishDirectory,installCommand,buildCommand,startCommand,customNginxConfiguration,dockerfile,dockerRegistryImageName,dockerRegistryImageTag,portsExposes,portsMappings,customNetworkAliases,customDockerRunOptions,httpBasicAuthUsername,httpBasicAuthPassword,preDeploymentCommand,preDeploymentCommandContainer,postDeploymentCommand,postDeploymentCommandContainer,isContainerLabelReadonlyEnabled,isContainerLabelEscapeEnabled,customLabels" />
<div class="application-settings-grid flex flex-col gap-6">
<x-application.settings-section id="application-details-section" title="Application details" helper="Name the application and choose the build strategy Coolify should use to deploy it." class="application-details-card">
@if ($buildPack === 'dockercompose')
@if ($buildPack === 'dockercompose' && filled($dockerComposeRaw))
<x-slot:actions>
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
x-on:click="$wire.dispatch('loadCompose', false)">
{{ $application->docker_compose_raw ? 'Reload compose' : 'Load compose' }}
Reload compose
</x-forms.button>
</x-slot:actions>
@endif
@@ -1,4 +1,5 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none">
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none" x-data
x-init="@js($loadComposeOnOpen) && $wire.loadComposeFile(true)">
<div>
@if ($application->build_pack === 'nixpacks')
<x-callout type="warning" title="Nixpacks is deprecated" class="mb-4">
@@ -31,7 +32,20 @@
</div>
<div class="shrink-0">
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
@if ($this->needsComposeFile())
<div class="flex items-center gap-1">
@can('update', $application)
<x-forms.button wire:click="loadComposeFile">Load compose</x-forms.button>
@endcan
@can('deploy', $application)
<x-forms.button id="application-mobile-actions" disabled default-class="split-action-main rounded-md!"
tooltip="Load the Compose file before you deploy.">
<x-reicon name="play-circle" class="size-3.5" />
Deploy
</x-forms.button>
@endcan
</div>
@else
@can('deploy', $application)
<x-split-action id="application-mobile-actions">
@if (str($application->status)->startsWith('exited'))
@@ -134,8 +148,17 @@
@if ($this->runningDeploymentUrl)
<x-deploying-indicator :href="$this->runningDeploymentUrl" class="mr-1" />
@endif
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
<span class="px-2 text-[13px] text-neutral-500 dark:text-fg-dim">Load a Compose file to deploy.</span>
@if ($this->needsComposeFile())
@can('update', $application)
<x-forms.button wire:click="loadComposeFile">Load compose</x-forms.button>
@endcan
@can('deploy', $application)
<x-forms.button id="application-desktop-actions" disabled default-class="split-action-main rounded-md!"
tooltip="Load the Compose file before you deploy.">
<x-reicon name="play-circle" class="size-3.5" />
Deploy
</x-forms.button>
@endcan
@else
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
@@ -0,0 +1,95 @@
<?php
use App\Livewire\Project\Application\Heading;
use App\Models\Application;
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 Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $keyId]);
$destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
));
$project = Project::create(['uuid' => (string) Str::uuid(), 'name' => 'Test Project', 'team_id' => $this->team->id]);
$this->application = Application::factory()->create([
'environment_id' => $project->environments()->first()->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'dockercompose',
'docker_compose_raw' => null,
'status' => 'exited',
]);
session(['currentTeam' => $this->team]);
});
test('an admin opening a compose application without a compose file loads it automatically', function () {
$this->actingAs($this->admin);
Livewire::test(Heading::class, ['application' => $this->application])
->assertSet('loadComposeOnOpen', true)
->assertSee('Load compose')
->assertSee('Load the Compose file before you deploy.')
->assertDontSee('Load a Compose file to deploy.');
});
test('a compose application that already has a compose file does not load it on open', function () {
$this->actingAs($this->admin);
$this->application->update(['docker_compose_raw' => "services:\n web:\n image: nginx\n"]);
Livewire::test(Heading::class, ['application' => $this->application])
->assertSet('loadComposeOnOpen', false)
->assertDontSee('Load compose');
});
test('the automatic load is skipped when the compose file is already loaded', function () {
$this->actingAs($this->admin);
$component = Livewire::test(Heading::class, ['application' => $this->application]);
$this->application->update(['docker_compose_raw' => "services:\n web:\n image: nginx\n"]);
$component->call('loadComposeFile', true)
->assertNotDispatched('composeFileLoaded')
->assertNotDispatched('error');
});
test('a member cannot load the compose file and does not load it on open', function () {
$this->actingAs($this->member);
Livewire::test(Heading::class, ['application' => $this->application])
->assertSet('loadComposeOnOpen', false)
->assertDontSee('Load compose')
->call('loadComposeFile')
->assertDispatched('error')
->assertNotDispatched('composeFileLoaded');
expect($this->application->refresh()->docker_compose_raw)->toBeNull();
});