feat(ui): unify resource headings with responsive action menus

Add responsive overflow handling and dedicated action menus for application,
service, database, and server headings, while improving upgrade and email
settings flows and returning 404s for missing deployment environments.
This commit is contained in:
Andras Bacsai committed 2026-08-13 12:17:16 +02:00
1 parent 592b537c61
commit 5f3108b68d
37 files changed
+1155 -733

No files matched your search

@@ -37,6 +37,7 @@ test('advanced action dropdown menus use the grid icon', function () {
$files = [
resource_path('views/components/applications/advanced.blade.php'),
resource_path('views/components/services/advanced.blade.php'),
resource_path('views/components/server/advanced.blade.php'),
];
foreach ($files as $path) {
@@ -206,6 +206,38 @@ test('admin can update email notification settings', function () {
expect($this->admin->can('update', $settings))->toBeTrue();
});
test('admin can save team smtp settings without a resend api key when resend is disabled', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'alerts@example.com')
->set('smtpFromName', 'Coolify')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('resendEnabled', false)
->set('resendApiKey', null)
->call('submitResend')
->assertHasNoErrors()
->assertNotDispatched('error');
});
test('admin cannot enable team resend without an api key', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'alerts@example.com')
->set('smtpFromName', 'Coolify')
->set('resendEnabled', true)
->set('resendApiKey', null)
->call('submitResend')
->assertDispatched('error');
expect($this->team->emailNotificationSettings()->first()->resend_enabled)->toBeFalse();
});
// --- Pushover ---
test('member cannot send test notification on pushover', function () {
@@ -0,0 +1,79 @@
<?php
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;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
InstanceSettings::unguarded(function () {
InstanceSettings::query()->create([
'id' => 0,
'is_registration_enabled' => true,
]);
});
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
});
it('returns 404 when the deployment environment does not exist', function () {
$this->get(route('project.application.deployment.show', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
'application_uuid' => $this->application->uuid,
'deployment_uuid' => 'qn5yfvo8clppyt44b75k7dav',
]))->assertNotFound();
});
it('returns 404 when the deployment index environment does not exist', function () {
$this->get(route('project.application.deployment.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
'application_uuid' => $this->application->uuid,
]))->assertNotFound();
});
it('returns 404 when the database backup index environment does not exist', function () {
$this->get(route('project.database.backup.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
'database_uuid' => 'missing-database',
]))->assertNotFound();
});
it('returns 404 when the database backup execution environment does not exist', function () {
$this->get(route('project.database.backup.execution', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
'database_uuid' => 'missing-database',
'backup_uuid' => 'missing-backup',
]))->assertNotFound();
});
+39
View File
@@ -0,0 +1,39 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
use Illuminate\Foundation\Exceptions\Renderer\Renderer;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
});
test('debug exceptions use laravel renderer instead of ignition', function () {
config(['app.debug' => true]);
expect(class_exists('Spatie\\LaravelIgnition\\IgnitionServiceProvider'))->toBeFalse()
->and(app()->bound(ExceptionRenderer::class))->toBeFalse()
->and(app()->bound(Renderer::class))->toBeTrue();
$response = app(ExceptionHandler::class)
->render(request(), new RuntimeException('default-laravel-exception-renderer'));
expect($response->getContent())
->toContain('default-laravel-exception-renderer')
->toContain('scheme-light-dark')
->toContain('dark:bg-neutral-900');
});
test('dev exception preview url renders the laravel debug page', function () {
config(['app.debug' => true]);
$this->get('/__exception')
->assertServerError()
->assertSee('RuntimeException', false)
->assertSee('Testing Laravel exception page')
->assertSee('scheme-light-dark', false);
});
@@ -0,0 +1,25 @@
<?php
test('team notification resend section matches instance email layout', function () {
$instance = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
$team = file_get_contents(resource_path('views/livewire/notifications/email.blade.php'));
preg_match('/settings-section title="Resend".*?<\/x-application.settings-section>/s', $instance, $instanceResend);
preg_match('/settings-section title="Resend".*?<\/x-application.settings-section>/s', $team, $teamResend);
expect($instanceResend[0] ?? '')
->not->toBeEmpty()
->toContain('grid gap-4 lg:grid-cols-2')
->toContain('id="resendEnabled"')
->toContain('id="resendApiKey"')
->not->toContain('lg:col-span-2')
->not->toContain('description=');
expect($teamResend[0] ?? '')
->not->toBeEmpty()
->toContain('grid gap-4 lg:grid-cols-2')
->toContain('id="resendEnabled"')
->toContain('id="resendApiKey"')
->not->toContain('lg:col-span-2')
->not->toContain('description=');
});
@@ -0,0 +1,36 @@
<?php
it('collapses resource header actions only when the top bar cannot fit them', function () {
$overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($overflow)
->toContain('data-resource-heading-overflow')
->toContain('measureInlineWidth')
->toContain('availableWidth')
->toContain('reserved += 200')
->toContain('hudSiblings')
->toContain('Actions')
->toContain('listbox-panel top-full! right-0! left-auto!')
->toContain("\$dispatch('resource-actions-toggled', { open })");
expect($css)
->toContain('.resource-heading-overflow-items')
->toContain('.resource-heading-overflow-items.is-measuring')
->toContain('.resource-heading-overflow.is-collapsed');
});
it('uses the overflow group for application, service, database, and server desktop actions', function () {
$files = [
resource_path('views/livewire/project/application/heading.blade.php') => 'application-desktop-actions',
resource_path('views/livewire/project/service/heading.blade.php') => 'service-desktop-actions',
resource_path('views/livewire/project/database/heading.blade.php') => 'database-desktop-actions',
resource_path('views/livewire/server/navbar.blade.php') => 'server-desktop-actions',
];
foreach ($files as $path => $id) {
expect(file_get_contents($path))
->toContain('<x-resource-heading-overflow')
->toContain($id);
}
});
@@ -113,24 +113,47 @@ it('places the account menu beside the desktop sidebar toggle while retaining it
->toContain("'bottom-full! left-0! right-auto! top-auto! mb-1!' => \$sidebar");
});
it('keeps advanced operations in a separated section at the bottom of actions menus', function () {
it('keeps advanced operations in a dedicated Advanced dropdown', function () {
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$applicationAdvanced = file_get_contents(resource_path('views/components/applications/advanced.blade.php'));
$serviceAdvanced = file_get_contents(resource_path('views/components/services/advanced.blade.php'));
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
$applicationDesktop = str($application)->after('resource-heading-actions flex')->toString();
$serviceDesktop = str($service)->after('resource-heading-actions flex')->toString();
expect($applicationDesktop)
->not->toContain('<x-applications.advanced')
->toContain('<x-applications.deploy')
->toContain('application-desktop-actions')
->toContain('role="separator"')
->toContain('Force deploy without cache')
->not->toContain('<x-applications.advanced')
->not->toContain('resource-heading-overflow-separator')
->not->toContain('Force deploy without cache')
->and($serviceDesktop)
->not->toContain('<x-services.advanced')
->toContain('<x-services.advanced')
->toContain('<x-services.restart')
->toContain('service-desktop-actions')
->toContain('role="separator"')
->toContain('Pull Latest Images & Restart')
->not->toContain('resource-heading-overflow-separator')
->not->toContain('Pull Latest Images & Restart');
expect(strpos($applicationDesktop, '<x-applications.links'))
->toBeLessThan(strpos($applicationDesktop, '<x-applications.deploy'))
->toBeLessThan(strpos($applicationDesktop, 'application-desktop-actions'));
expect(strpos($serviceDesktop, '<x-services.links'))
->toBeLessThan(strpos($serviceDesktop, '<x-services.advanced'))
->toBeLessThan(strpos($serviceDesktop, '<x-services.restart'))
->toBeLessThan(strpos($serviceDesktop, 'service-desktop-actions'));
expect($applicationAdvanced)
->toContain('Advanced')
->toContain('name="grid"')
->not->toContain('Force deploy without cache')
->and($serviceAdvanced)
->toContain('Advanced')
->toContain('name="grid"')
->not->toContain('Pull Latest Images & Restart')
->not->toContain('Pull latest and restart')
->toContain('Force Restart')
->toContain('Force Deploy')
->toContain('Force Cleanup Containers')
@@ -142,29 +165,36 @@ it('keeps advanced operations in a separated section at the bottom of actions me
expect(substr_count($application, 'resource-heading-navbar'))->toBe(1);
});
it('groups application lifecycle controls in an actions dropdown', function () {
it('lists desktop application lifecycle controls inline and collapses them when space runs out', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
->toContain('application-desktop-actions')
->toContain('Actions')
->toContain('listbox-panel top-full! right-0! left-auto!')
->toContain('Redeploy')
->toContain('<x-resource-heading-overflow')
->toContain('<x-applications.deploy')
->toContain('Restart')
->toContain('Stop')
->toContain('Deploy');
->not->toContain('listbox-option');
expect($overflow)
->toContain('Actions')
->toContain('listbox-panel top-full! right-0! left-auto!')
->toContain('measureInlineWidth')
->toContain('availableWidth');
});
it('raises the desktop top bar while any resource dropdown is open', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$dropdowns = [
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/components/resource-heading-overflow.blade.php'),
resource_path('views/components/applications/links.blade.php'),
resource_path('views/components/services/links.blade.php'),
resource_path('views/components/applications/deploy.blade.php'),
resource_path('views/components/services/advanced.blade.php'),
resource_path('views/components/services/restart.blade.php'),
resource_path('views/components/server/advanced.blade.php'),
];
expect($layout)
@@ -178,15 +208,48 @@ it('raises the desktop top bar while any resource dropdown is open', function ()
}
});
it('keeps deploy in the actions menu alongside advanced operations', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
it('groups service restart options in a Restart dropdown', function () {
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$restart = file_get_contents(resource_path('views/components/services/restart.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
->toContain("@if (str(\$application->status)->startsWith('exited'))")
->toContain('<x-services.restart')
->not->toContain('Pull Latest Images & Restart')
->and($restart)
->toContain('Restart current version')
->toContain('Pull latest and restart')
->toContain("\$wire.dispatch('pullAndRestartEvent')")
->toContain('service-restart-trigger');
$mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
expect($mobile)
->toContain('Restart current version')
->toContain('Pull latest and restart')
->not->toContain('Pull Latest Images & Restart');
});
it('groups application deploy options in a Deploy dropdown', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$deploy = file_get_contents(resource_path('views/components/applications/deploy.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
->toContain('<x-applications.deploy')
->toContain('id="application-desktop-actions"')
->not->toContain('Force deploy without cache')
->and($deploy)
->toContain('Deploy')
->toContain('Force deploy without cache');
->toContain('Deploy (without cache)')
->toContain('force_deploy_without_cache')
->toContain('deploy(true)');
$mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
expect($mobile)
->toContain('Deploy (without cache)')
->not->toContain('Force deploy without cache');
});
it('renders configuration warnings as navbar popovers instead of floating notifications', function () {
+23 -8
View File
@@ -42,19 +42,34 @@ it('uses the branded input focus state for the server filter', function () {
->not->toContain('<x-reicon name="check"');
});
it('groups all desktop proxy controls in the server actions dropdown', function () {
it('lists desktop proxy controls inline and keeps Traefik and refresh in Advanced', function () {
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
$desktopActions = str($navbar)->after('id="server-desktop-actions"')->before('@endteleport')->toString();
$overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
$advanced = file_get_contents(resource_path('views/components/server/advanced.blade.php'));
$desktopActions = str($navbar)->after("@teleport('#resource-action-hud-slot')")->before('@endteleport')->toString();
expect($desktopActions)
->toContain('Actions')
->toContain('Traefik Dashboard')
->toContain('name="external-link" class="size-3! opacity-70"')
->toContain('class="flex size-4 shrink-0 items-center justify-center"')
->toContain('Restart Proxy')
->toContain('Stop Proxy')
->toContain('Start Proxy')
->toContain('Refresh Proxy Status')
->toContain('listbox-panel')
->toContain('<x-server.advanced')
->not->toContain('Traefik Dashboard')
->not->toContain('Refresh Proxy Status')
->not->toContain('resource-heading-overflow-separator')
->not->toContain('<x-modal-confirmation');
expect(strpos($desktopActions, '<x-server.advanced'))
->toBeLessThan(strpos($desktopActions, 'id="server-desktop-actions"'));
expect($advanced)
->toContain('Advanced')
->toContain('name="grid"')
->toContain('Traefik Dashboard')
->toContain('name="external-link" class="size-3! opacity-70"')
->toContain('class="flex size-4 shrink-0 items-center justify-center"')
->toContain('Refresh Proxy Status');
expect($overflow)
->toContain('Actions')
->toContain('listbox-panel');
});
@@ -30,10 +30,9 @@ it('matches application action bar behavior for services and databases', functio
foreach ([$service, $database] as $heading) {
expect($heading)
->toContain('xl:fixed xl:top-14 xl:right-4')
->toContain('@teleport(\'#resource-action-hud-slot\')')
->toContain('xl:w-auto')
->toContain('Actions')
->toContain('listbox-panel top-full! right-0! left-auto!')
->toContain('<x-resource-heading-overflow')
->not->toContain('hidden lg:block lg:h-12');
}
@@ -0,0 +1,137 @@
<?php
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function setupInstanceAdminForEmailSettings(): User
{
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
InstanceSettings::forceCreate([
'id' => 0,
'smtp_enabled' => false,
'resend_enabled' => false,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
return $user;
}
function smtpSetupPayload(): array
{
return [
'smtpFromAddress' => 'alerts@example.com',
'smtpFromName' => 'Coolify',
'smtpHost' => 'smtp.example.com',
'smtpPort' => '587',
'smtpEncryption' => 'starttls',
'resendEnabled' => false,
'resendApiKey' => null,
];
}
test('saving smtp settings does not require a resend api key when resend is disabled', function () {
$user = setupInstanceAdminForEmailSettings();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->fill(smtpSetupPayload())
->set('smtpEnabled', true)
->call('submitSmtp')
->assertHasNoErrors()
->assertNotDispatched('error');
$settings = InstanceSettings::find(0);
expect($settings->smtp_enabled)->toBeTrue()
->and($settings->smtp_host)->toBe('smtp.example.com')
->and($settings->resend_enabled)->toBeFalse();
});
test('saving transactional email settings does not require a resend api key when resend is disabled', function () {
$user = setupInstanceAdminForEmailSettings();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->fill(smtpSetupPayload())
->call('submit')
->assertHasNoErrors()
->assertNotDispatched('error');
$settings = InstanceSettings::find(0);
expect($settings->smtp_host)->toBe('smtp.example.com')
->and($settings->resend_enabled)->toBeFalse()
->and($settings->resend_api_key)->toBeNull();
});
test('enabling smtp delivery does not require a resend api key when resend is disabled', function () {
$user = setupInstanceAdminForEmailSettings();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->fill(smtpSetupPayload())
->set('smtpEnabled', true)
->call('instantSaveSmtp')
->assertHasNoErrors()
->assertNotDispatched('error');
$settings = InstanceSettings::find(0);
expect($settings->smtp_enabled)->toBeTrue()
->and($settings->resend_enabled)->toBeFalse();
});
test('disabling resend does not require a resend api key', function () {
$user = setupInstanceAdminForEmailSettings();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->fill(smtpSetupPayload())
->set('resendEnabled', false)
->set('resendApiKey', null)
->call('submitResend')
->assertHasNoErrors()
->assertNotDispatched('error');
});
test('enabling resend still requires an api key', function () {
$user = setupInstanceAdminForEmailSettings();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->fill(smtpSetupPayload())
->set('resendEnabled', true)
->set('resendApiKey', null)
->call('submitResend')
->assertDispatched('error');
expect(InstanceSettings::find(0)->resend_enabled)->toBeFalse();
});
test('email settings page has a single unsaved bar so smtp save cannot hit resend validation', function () {
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
expect(substr_count($view, '<x-unsaved-bar'))->toBe(1)
->and($view)->toContain('action="submit"')
->and($view)->not->toContain('action="submitResend"');
});
+30
View File
@@ -34,6 +34,36 @@ it('initializes latest version during mount from cached versions data', function
->assertSee('4.0.0-beta.999');
});
it('does not highlight the current upgrade stage with the warning yellow accent', function () {
$progressView = file_get_contents(resource_path('views/components/upgrade-progress.blade.php'));
expect($progressView)
->toContain('bg-neutral-100 text-neutral-900 dark:bg-white/[0.08] dark:text-fg')
->not->toContain('dark:text-warning')
->not->toContain('dark:bg-warning');
});
it('uses a current-color spinner on upgrade stages instead of the brand purple loader', function () {
$progressView = file_get_contents(resource_path('views/components/upgrade-progress.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($progressView)
->toContain('spinner-current')
->not->toContain('loading-indicator')
->and($appCss)
->toContain('.dark .animate-spin.spinner-current')
->toContain('html[data-theme="custom"] .animate-spin.spinner-current')
->toContain('color: inherit !important;');
});
it('treats a brief upgrade poll miss as a reconnect, not a lost-contact failure', function () {
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
expect($upgradeView)
->toContain('Reconnecting. This is expected during an upgrade...')
->not->toContain('Lost contact with Coolify');
});
it('uses sidebar state css instead of nested alpine state for upgrade labels', function () {
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
$utilitiesCss = file_get_contents(resource_path('css/utilities.css'));
+10
View File
@@ -128,6 +128,16 @@ it('waits for the running version to match the target before showing reload', fu
->toContain('livewireFailures');
});
it('treats a healthy instance without a version header as ready only after downtime', function () {
$upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php');
expect($upgradeView)
->toContain('instanceWentDown')
->toContain('isReadyToReload')
->toContain('startHealthWatch')
->toContain('data.status === \'none\'');
});
it('starts the upgrade after the Livewire response so status polling is not blocked', function () {
$upgradeComponent = file_get_contents(__DIR__.'/../../app/Livewire/Upgrade.php');