feat(ui): unify resource nav sidebars and polish project layouts

Extract application/database/service configuration sidebars and
unified headings; improve clone-to-environment, embedded deployments,
storage actions, OAuth icons, terminal, and shared form components.
This commit is contained in:
Andras Bacsai committed 2026-08-05 17:56:59 +02:00
1 parent 2291c3fa1c
commit 5de6f97256
85 files changed
+2087 -981

No files matched your search

@@ -0,0 +1,20 @@
<?php
test('compose actions are grouped with the application details header', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$applicationDetails = str($view)
->after('class="application-details-card">')
->before('</x-application.settings-section>')
->toString();
expect($applicationDetails)
->toContain('<x-slot:actions>')
->toContain('x-on:click="$wire.dispatch(\'loadCompose\', false)"')
->toContain('Reload compose');
});
test('docker compose heading separates its title and action', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($view)->toContain('<div class="flex items-center gap-4">');
});
@@ -129,16 +129,16 @@ it('does not mark settings tab active on deployment logs', function () {
->assertSuccessful()
->getContent();
expect($html)->toContain('Deployment Logs');
expect($html)->toContain('Deployment');
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(0);
// Deployment Logs should be the active primary tab instead.
// Deployment now lives in the Logs section of the settings sidebar.
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Deployment Logs\s*<\/a>/s',
'/<a[^>]*menu-item-active[^>]*>.*?Deployment.*?<\/a>/s',
$html
))->toBe(1);
});
@@ -273,6 +273,37 @@ YAML;
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com');
});
test('applicationParser reads redirect settings from the compose service domain', function () {
$this->server->proxy->set('type', 'TRAEFIK');
$this->server->save();
ServerSetting::query()
->where('server_id', $this->server->id)
->update(['generate_exact_labels' => true]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
YAML,
'docker_compose_domains' => json_encode([
'frontend' => [
'domain' => 'https://example.com,https://www.example.com',
'redirect' => 'www',
],
]),
]);
$parsedCompose = applicationParser($application);
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=${1}://www.${2}')))->toBeTrue();
});
test('compose domain reconciliation preserves stored domains when parsing returns no services', function () {
$storedDomains = json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
@@ -67,6 +67,26 @@ test('cloneTo allows destination belonging to own team', function () {
expect(Application::count())->toBe(2);
});
test('cloneTo can place the cloned resource in another environment', function () {
$targetEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationA->uuid, $targetEnvironment->id)
->assertRedirect();
$clone = Application::whereKeyNot($this->applicationA->id)->firstOrFail();
expect($clone->environment_id)->toBe($targetEnvironment->id);
});
test('cloneTo rejects an environment belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationA->uuid, $this->environmentB->id)
->assertHasErrors('environment_id');
expect(Application::count())->toBe(1);
});
test('moveTo rejects environment belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('moveTo', $this->environmentB->id);
@@ -0,0 +1,17 @@
<?php
it('uses a database list editor instead of a comma-separated text field', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
expect($view)
->toContain('<div class="grid w-full gap-4">')
->toContain("value: @entangle('databasesToBackup').live")
->toContain('class="chip-input"')
->toContain('class="chip font-mono"')
->toContain('class="chip-remove"')
->toContain('addDatabase()')
->toContain('removeDatabase(index)')
->toContain('Type a database and press Enter');
expect(substr_count($view, '<x-forms.input label="Databases to back up"'))->toBe(1);
});
@@ -0,0 +1,29 @@
<?php
it('matches the storage backup overview layout', function () {
$index = file_get_contents(resource_path('views/livewire/project/database/backup/index.blade.php'));
$schedules = file_get_contents(resource_path('views/livewire/project/database/scheduled-backups.blade.php'));
$component = file_get_contents(app_path('Livewire/Project/Database/Backup/Index.php'));
expect($index)
->toContain('title="Database backups"')
->toContain('Schedules')
->toContain('Enabled')
->toContain('Total executions')
->toContain('application-settings-form flex min-w-0 flex-col gap-6')
->and($schedules)
->toContain('placeholder="Search backups"')
->toContain('data-table overflow-hidden rounded-xl border')
->toContain('<span class="block truncate text-[12px] font-semibold')
->toContain('<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>')
->and($component)->toContain("withCount('executions')");
});
it('returns from database backup settings to the database', function () {
$sidebar = file_get_contents(resource_path('views/components/backup-sidebar.blade.php'));
expect($sidebar)
->toContain("'back' => 'project.database.configuration'")
->toContain("'Back to database'")
->toContain("except('backup_uuid')");
});
@@ -0,0 +1,17 @@
<?php
it('keeps editable standalone database storage actions in the table action column', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/Storages/All.php'));
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect($component)
->toContain('$this->showActionsColumn = $this->canUpdate;')
->toContain('$this->showBackupAction = $this->resource instanceof Application')
->toContain('if (! $this->showBackupAction)')
->and($view)
->toContain('@if ($showActionsColumn)')
->toContain('@if ($showBackupAction)')
->toContain('title="New Scheduled Backup"')
->toContain('<livewire:project.database.create-scheduled-backup :database="$resource"')
->toContain('Backup');
});
@@ -0,0 +1,21 @@
<?php
use Illuminate\Support\Facades\Blade;
it('uses the Coolify base background for all empty states', function () {
$html = Blade::render('<x-empty title="Nothing found" />');
$css = file_get_contents(resource_path('css/app.css'));
expect($html)
->toContain('empty-state')
->not->toContain('bg-neutral-50')
->not->toContain('dark:bg-white/[0.02]')
->and($css)
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
->toContain('.application-settings-section-body.is-flush:has(> .empty-state:only-child)')
->toContain('padding: 1rem;')
->toContain('.empty-state')
->toContain('background: var(--coollabs-base);')
->toContain('.loading-state-card')
->toContain('border: 1px dashed var(--coollabs-line);');
});
@@ -0,0 +1,17 @@
<?php
it('uses highlighted styling for enable toggle actions', function () {
$views = [
resource_path('views/components/notification/channel-actions.blade.php'),
resource_path('views/livewire/settings-oauth.blade.php'),
resource_path('views/livewire/project/shared/scheduled-task/show.blade.php'),
];
foreach ($views as $view) {
expect(file_get_contents($view))->toContain('isHighlighted');
}
$terminalAccess = file_get_contents(resource_path('views/livewire/server/security/terminal-access.blade.php'));
expect($terminalAccess)->toContain(':isHighlightedButton="!$isTerminalEnabled"');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
it('uses the current listbox design for environment variable suggestions', function () {
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
expect($view)
->toContain('class="listbox-panel')
->toContain('class="listbox-option justify-start! gap-2.5!"')
->toContain("'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex")
->toContain('border-warning/25 bg-warning/10')
->toContain('border-emerald-500/25 bg-emerald-500/10')
->not->toContain('dark:bg-coolgray-100');
});
+15 -1
View File
@@ -62,7 +62,21 @@ test('listbox forwards dynamic disabled state to its trigger', function () {
expect($listbox)->toContain("\$attributes->whereStartsWith('x-bind:disabled')");
expect($operations)
->toContain('x-bind:disabled="!selectedCloneServer"')
->toContain('selectedCloneServer: null')
->toContain('selectedCloneProject: null')
->toContain('selectedCloneEnvironment: null')
->toContain('currentServerId: @js($resource->destination->server->id)')
->toContain('currentDestinationUuid: @js($resource->destination->uuid)')
->toContain("server.id == this.currentServerId ? ' (current)' : ''")
->toContain("destination.uuid == this.currentDestinationUuid ? ' (current)' : ''")
->toContain('selectedCloneServer = null;')
->toContain('placeholder="Choose a server…"')
->toContain("this.selectedCloneServer === null || this.selectedCloneServer === ''")
->toContain("x-bind:disabled=\"selectedCloneServer === null || selectedCloneServer === ''\"")
->toContain('x-model="selectedCloneProject"')
->toContain('x-model="selectedCloneEnvironment"')
->toContain('$wire.cloneTo(selectedCloneDestination)')
->toContain('$wire.cloneTo(@js($resource->destination->uuid), selectedCloneEnvironment)')
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
});
@@ -59,19 +59,22 @@ it('does not render the notification for preview deployment toggles', function (
->assertSet('isConfigurationChanged', false);
});
it('renders the changed configuration labels', function () {
it('renders the changed configuration labels without a second backend request', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
// Banner summary is always available; full change rows load on demand.
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('The latest configuration has not been applied')
->assertSee('A rebuild is required.')
->assertDontSee('Build command')
->call('refreshConfigurationChanges')
->assertSee('Build command');
$view = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php'));
expect($view)
->toContain('x-on:click="configurationDiffModalOpen = true"')
->not->toContain('$wire.refreshConfigurationChanges()');
});
it('refreshes configuration changes when the event is received', function () {
@@ -88,7 +91,6 @@ it('refreshes configuration changes when the event is received', function () {
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->call('refreshConfigurationChanges')
->assertSee('Build command');
});
@@ -113,7 +115,6 @@ it('shows an unapplied configuration warning after a directory mount is added',
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->assertSee('Please redeploy to apply the new configuration.')
->call('refreshConfigurationChanges')
->assertSee('Directory mount');
});
@@ -124,7 +125,6 @@ it('refreshes stale modal configuration diff before opening changes', function (
$application->update(['build_command' => 'pnpm build']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges')
->assertSee('Build command')
->assertDontSee('Start command');
@@ -134,13 +134,13 @@ it('refreshes stale modal configuration diff before opening changes', function (
]);
$component
->call('refreshConfigurationChanges')
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('Start command')
->assertDontSee('Build command');
});
it('keeps full configuration change rows out of the initial Livewire snapshot', function () {
it('includes full configuration change rows in the initial Livewire snapshot', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
@@ -148,12 +148,7 @@ it('keeps full configuration change rows out of the initial Livewire snapshot',
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
expect($component->get('isConfigurationChanged'))->toBeTrue()
->and($component->get('configurationDiff'))->toHaveKeys(['count', 'requires_build'])
->and($component->get('configurationDiff'))->not->toHaveKey('changes');
$component->call('refreshConfigurationChanges');
expect($component->get('configurationDiff'))->toHaveKey('changes')
->and($component->get('configurationDiff'))->toHaveKey('changes')
->and(data_get($component->get('configurationDiff'), 'changes'))->not->toBeEmpty();
});
@@ -177,8 +172,7 @@ it('redacts unlocked environment values for team members in the change list', fu
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges');
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
@@ -211,7 +205,6 @@ it('redacts newly added environment values for team members', function () {
]);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges')
->assertSee('API_TOKEN')
->assertSee('Current')
->assertSee('New');
+20
View File
@@ -0,0 +1,20 @@
<?php
it('uses the current neutral loading indicator styles', function () {
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
expect($loading)
->toContain('gap-2 text-[13px] text-neutral-500 dark:text-fg-dim')
->toContain('size-4 shrink-0 animate-spin')
->toContain('stroke="currentColor" stroke-width="2"')
->not->toContain('text-coollabs')
->not->toContain('dark:text-warning');
});
it('shows runtime container loading inside a settings card', function () {
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
expect($logs)
->toContain('application-settings-section-body flex min-h-40 w-full items-center justify-center')
->toContain('<x-loading text="Loading containers" />');
});
@@ -0,0 +1,17 @@
<?php
it('shows the shared hover arrow on project and server cards', function () {
$views = [
resource_path('views/livewire/project/index.blade.php'),
resource_path('views/livewire/server/index.blade.php'),
];
foreach ($views as $view) {
$contents = file_get_contents($view);
expect($contents)
->toContain('<x-reicon name="arrow-right"')
->toContain('group-hover:translate-x-0.5 group-hover:opacity-100')
->toContain('pointer-events-none absolute top-1/2 right-3');
}
});
@@ -14,30 +14,205 @@ it('uses a single unified navbar for application, service, database, and server
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)
->toContain('resource-heading-navbar')
->toContain('justify-between')
->toContain('border-l border-neutral-200 pl-1');
expect($contents)->toContain('resource-heading-navbar');
}
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
expect($application)
->toContain('justify-start')
->toContain('xl:justify-end')
->toContain('xl:w-auto')
->toContain('xl:bg-transparent')
->toContain('xl:fixed')
->toContain('xl:top-14')
->toContain('xl:hidden')
->not->toContain('application-primary-tabs')
->not->toContain("typeof collapsed !== 'undefined'")
->not->toContain('Spacer: in-flow stand-in')
->not->toContain('hidden lg:block lg:h-12')
->not->toContain('border-l border-neutral-200 pl-1');
});
it('keeps links with the menu group and actions on the right for applications and services', function () {
it('keeps application links next to advanced actions on the right', 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'));
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
$linksBeforeActionsDivider = function (string $contents, string $linksComponent): bool {
$linksPos = strpos($contents, $linksComponent);
$dividerPos = strpos($contents, 'border-l border-neutral-200 pl-1');
$desktop = str($application)->after('resource-heading-actions flex')->toString();
$advancedPosition = strpos($desktop, '<x-applications.advanced');
$linksPosition = strpos($desktop, '<x-applications.links');
return $linksPos !== false && $dividerPos !== false && $linksPos < $dividerPos;
};
expect($linksBeforeActionsDivider($application, '<x-applications.links'))->toBeTrue();
expect($linksBeforeActionsDivider($service, '<x-services.links'))->toBeTrue();
expect($advancedPosition)->not->toBeFalse()
->and($linksPosition)->not->toBeFalse()
->and($linksPosition)->toBeGreaterThan($advancedPosition)
->and($links)->toContain('listbox-panel top-full! right-0! left-auto!')
->and($links)->toContain('listbox-option justify-start! gap-2.5!')
->and($links)->not->toContain('md:left-0 md:right-auto');
// Desktop layer uses exactly one unified pill bar (mobile section may still use its own pill).
expect(substr_count($application, 'resource-heading-navbar'))->toBe(1);
expect(substr_count($service, 'resource-heading-navbar'))->toBe(1);
});
it('groups application lifecycle controls in an actions dropdown', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.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('Restart')
->toContain('Stop')
->toContain('Deploy');
});
it('shows deploy directly when it is the only available lifecycle action', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
->toContain("@if (str(\$application->status)->startsWith('exited'))")
->toContain('id="application-desktop-deploy"')
->toContain('@else')
->toContain('id="application-desktop-actions"');
});
it('moves application backups from the top tabs into the settings sidebar', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
$backup = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php'));
expect($heading)->not->toContain("'label' => 'Backups'")
->and($configuration)->toContain('<x-application.configuration-sidebar')
->and($backup)->toContain('<x-application.configuration-sidebar');
});
it('moves application terminal and logs from the top tabs into the settings sidebar', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($heading)
->not->toContain("'label' => 'Terminal'")
->not->toContain("'label' => 'Deployment'")
->not->toContain("'label' => 'Runtime'")
->and($sidebar)
->toContain("'label' => 'Terminal'")
->toContain("'label' => 'Deployment'")
->toContain("'label' => 'Runtime'")
->toContain("'Logs' => ['Deployment', 'Runtime']")
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
});
it('groups application automation pages separately from build and deploy', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($sidebar)
->toContain("'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments']")
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
});
it('centers the rollback image loading state across the card', function () {
$rollback = file_get_contents(resource_path('views/livewire/project/application/rollback.blade.php'));
expect($rollback)
->toContain('class="w-full" wire:target="loadImages" wire:loading')
->toContain('flex items-center justify-center');
});
it('shows pull request loading feedback inside its settings card', function () {
$previews = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
expect($previews)
->toContain('class="w-full" wire:loading wire:target="load_prs"')
->toContain('min-h-32 items-center justify-center')
->toContain('<x-loading text="Loading pull requests…"');
});
it('allows the shared empty state to control the storage backups background', function () {
$backups = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php'));
expect($backups)
->toContain("'application-settings-section-body w-full'")
->toContain("'is-flush' => \$backups->isNotEmpty()")
->not->toContain('application-settings-section-body is-flush w-full bg-transparent!');
});
it('keeps the application settings sidebar below the fixed header while scrolling', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($sidebar)->toContain('application-settings-navigation')
->and($css)->toContain('.application-settings-workspace > .application-settings-navigation')
->and($css)->toContain('top: 4rem;')
->and($css)->toContain('max-height: calc(100dvh - 5rem);');
});
it('builds application sidebar routes independently of the current request route', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($sidebar)
->not->toContain('get_route_parameters()')
->toContain("'project_uuid' => \$application->environment->project->uuid")
->toContain("'environment_uuid' => \$application->environment->uuid")
->toContain("'application_uuid' => \$application->uuid");
});
it('keeps the deployment log sidebar fixed in the layout without a top gap', function () {
$deployment = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($deployment)->toContain(':flush="true"')
->and($css)->toContain('.application-settings-navigation.is-flush')
->and($css)->toContain('position: static;')
->and($css)->toContain('overflow: visible;');
});
it('removes desktop top spacing from the deployment log viewer', function () {
$deployment = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
expect($deployment)->toContain("'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-0'");
});
it('uses a distinct runtime log icon in the sidebar', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($sidebar)->toContain("'Runtime' => 'unordered-list'");
});
it('shows deployment history above the selected deployment logs', function () {
$indexClass = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
$indexView = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$showView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
expect($indexClass)
->toContain('public bool $embedded = false;')
->toContain('public ?string $selectedDeploymentUuid = null;')
->and($indexView)
->toContain("'data-table-row-active' => \$selectedDeploymentUuid")
->and($showView)
->toContain('<livewire:project.application.deployment.index :embedded="true"')
->toContain(':selected-deployment-uuid="$deployment_uuid"');
});
it('fits the combined deployment history and logs within the desktop viewport', function () {
$indexClass = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
$showView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
expect($indexClass)->toContain('$this->defaultTake = 3;')
->and($showView)
->toContain('xl:h-[calc(100dvh-7.5rem)]')
->toContain('xl:overflow-hidden')
->toContain('xl:flex-1');
});
it('uses only the healthcheck toggle action to communicate enabled state', function () {
$healthcheck = file_get_contents(resource_path('views/livewire/project/shared/health-checks.blade.php'));
expect($healthcheck)
->not->toContain('<x-status-badge')
->toContain('buttonTitle="Enable"')
->toContain('Disable');
});
it('keeps Links dropdowns outside the scrollable tabs strip', function () {
@@ -46,40 +221,16 @@ it('keeps Links dropdowns outside the scrollable tabs strip', function () {
$css = file_get_contents(resource_path('css/app.css'));
$tabsComponent = file_get_contents(resource_path('views/components/resource-heading-tabs.blade.php'));
// Links must not sit inside the overflow-x-auto tabs scroller (causes scrollbar on open).
// Application Links are standalone on mobile and next to actions on desktop.
expect($application)
->toContain('<x-resource-heading-tabs')
->not->toContain('<x-resource-heading-tabs')
->toContain('resource-heading-menus')
->toContain('<x-applications.links');
// Mobile and desktop each have a tabs + menus pair; verify every pair.
preg_match_all('/<x-resource-heading-tabs/', $application, $applicationTabsMatches, PREG_OFFSET_CAPTURE);
preg_match_all('/resource-heading-menus/', $application, $applicationMenusMatches, PREG_OFFSET_CAPTURE);
expect($applicationTabsMatches[0])->toHaveCount(2);
expect($applicationMenusMatches[0])->toHaveCount(2);
foreach ($applicationTabsMatches[0] as $index => [$match, $tabsOffset]) {
$menusOffset = $applicationMenusMatches[0][$index][1];
$overflowInTabs = substr($application, $tabsOffset, $menusOffset - $tabsOffset);
expect($overflowInTabs)->not->toContain('<x-applications.links');
expect(strpos($application, '<x-applications.links', $tabsOffset))
->toBeGreaterThan($menusOffset);
}
preg_match_all('/<x-resource-heading-tabs/', $service, $serviceTabsMatches, PREG_OFFSET_CAPTURE);
preg_match_all('/resource-heading-menus/', $service, $serviceMenusMatches, PREG_OFFSET_CAPTURE);
expect($serviceTabsMatches[0])->toHaveCount(2);
expect($serviceMenusMatches[0])->toHaveCount(2);
foreach ($serviceTabsMatches[0] as $index => [$match, $tabsOffset]) {
$menusOffset = $serviceMenusMatches[0][$index][1];
$overflowInTabs = substr($service, $tabsOffset, $menusOffset - $tabsOffset);
expect($overflowInTabs)->not->toContain('<x-services.links');
expect(strpos($service, '<x-services.links', $tabsOffset))
->toBeGreaterThan($menusOffset);
}
expect($service)
->not->toContain('<x-resource-heading-tabs')
->toContain('resource-heading-menus')
->toContain('<x-services.links');
expect($css)
->toContain('.resource-heading-tabs::-webkit-scrollbar')
@@ -101,22 +252,40 @@ it('keeps Links dropdowns outside the scrollable tabs strip', function () {
->toContain('livewire:navigated');
});
it('shows an icon in application and service Links controls', function () {
$applicationLinks = file_get_contents(resource_path('views/components/applications/links.blade.php'));
$serviceLinks = file_get_contents(resource_path('views/components/services/links.blade.php'));
expect($applicationLinks)->toContain("@props(['application', 'fullWidth' => false])")
->toContain('<x-reicon name="external-link"')
->and($serviceLinks)->toContain('<x-reicon name="external-link"')
->and($applicationLinks)->not->toContain('<x-external-link')
->and($serviceLinks)->not->toContain('<x-external-link');
});
it('shows application and service Links on mobile headings', 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'));
$mobileApplicationSection = str($application)->between('class="w-full md:hidden"', 'class="hidden w-full items-center md:flex')->toString();
$mobileApplicationSection = str($application)->between('class="w-full xl:hidden"', 'class="hidden w-full items-center xl:fixed')->toString();
$mobileServiceSection = str($service)->between('class="w-full md:hidden"', 'class="hidden w-full items-center md:flex')->toString();
expect($mobileApplicationSection)
->toContain('<x-applications.links')
->toContain('full-width')
->toContain('resource-heading-menus')
->toContain('<x-resource-heading-tabs');
->not->toContain('<x-resource-heading-tabs')
->not->toContain("'label' => 'Settings'");
$applicationLinks = file_get_contents(resource_path('views/components/applications/links.blade.php'));
expect($applicationLinks)
->toContain("'button w-full justify-between' => \$fullWidth")
->toContain('<x-reicon name="chevron-down"');
expect($mobileServiceSection)
->toContain('<x-services.links')
->toContain('resource-heading-menus')
->toContain('<x-resource-heading-tabs');
->not->toContain('<x-resource-heading-tabs');
// One mobile + one desktop instance.
expect(substr_count($application, '<x-applications.links'))->toBe(2);
@@ -125,9 +294,6 @@ it('shows application and service Links on mobile headings', function () {
it('uses overflow scroll arrows on resource heading navbars', function () {
$files = [
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/components/project/navbar.blade.php'),
];
+1 -1
View File
@@ -436,5 +436,5 @@ test('resource operations explains why build servers cannot be clone targets', f
Livewire::test(ResourceOperations::class, ['resource' => $source])
->assertSet('buildServers', fn ($servers) => $servers->contains('id', $buildServer->id))
->assertSee('Dedicated Builder')
->assertSee('Build server — cannot host resources');
->assertSee('build server');
});
@@ -0,0 +1,113 @@
<?php
it('moves service and database page navigation into their sidebars', function () {
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
$serviceConfiguration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
$databaseSidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
expect($serviceHeading)->not->toContain('<x-resource-heading-tabs')
->and($databaseHeading)->not->toContain('<x-resource-heading-tabs')
->and($serviceConfiguration)
->toContain("['label' => 'Backups'")
->toContain("['label' => 'Runtime'")
->toContain("['label' => 'Terminal'")
->and($databaseSidebar)
->toContain("['label' => 'Backups'")
->toContain("['label' => 'Runtime'")
->toContain("['label' => 'Terminal'");
});
it('matches application action bar behavior for services and databases', function () {
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$database = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
foreach ([$service, $database] as $heading) {
expect($heading)
->toContain('xl:fixed xl:top-14 xl:right-4')
->toContain('xl:w-auto')
->toContain('Actions')
->toContain('listbox-panel top-full! right-0! left-auto!')
->not->toContain('hidden lg:block lg:h-12');
}
expect($service)->toContain('id="service-desktop-actions"')
->and($database)->toContain('id="database-desktop-actions"');
});
it('keeps database and service sidebar sections in the application sequence', function () {
$database = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
$service = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
expect($database)
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup']")
->toContain("'Automation' => ['Webhooks', 'Healthcheck']")
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']");
expect($service)
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks']")
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
});
it('shows the database sidebar on backup pages', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'));
$backups = file_get_contents(resource_path('views/livewire/project/database/backup/index.blade.php'));
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
expect($configuration)->toContain('<x-database.configuration-sidebar')
->and($backups)
->toContain('<x-database.configuration-sidebar')
->toContain('current-route="project.database.backup.index"')
->and($sidebar)
->toContain("str(\$currentRoute)->startsWith('project.database.backup')");
});
it('uses a distinct backup icon across resource sidebars', function () {
$sidebars = [
resource_path('views/components/application/configuration-sidebar.blade.php'),
resource_path('views/components/database/configuration-sidebar.blade.php'),
resource_path('views/livewire/project/service/configuration.blade.php'),
];
foreach ($sidebars as $sidebar) {
$contents = file_get_contents($sidebar);
expect($contents)->toMatch("/['\"]Backups['\"].*['\"]database['\"]/s");
}
});
it('shows the database sidebar on runtime logs', function () {
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
expect($logs)
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
->toContain('<x-database.configuration-sidebar :database="$resource" current-route="project.database.logs"')
->toContain('loading-state-card');
});
it('shows the database sidebar in the terminal', function () {
$terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($terminal)
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
->toContain('<x-database.configuration-sidebar :database="$resource" current-route="project.database.command"');
});
it('shows the service sidebar on runtime logs and terminal pages', function () {
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
$terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$sidebar = file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php'));
expect($logs)
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.logs"')
->and($terminal)
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.command"')
->and($sidebar)
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
});
@@ -32,3 +32,45 @@ it('requires the provider-specific fields used by oauth enablement', function ()
'oauth_settings_map.authentik.base_url',
]);
});
it('uses monochrome brand logos for each oauth provider in the sidebar', function () {
$view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
$providers = [
'authentik',
'azure',
'bitbucket',
'clerk',
'discord',
'github',
'gitlab',
'google',
'infomaniak',
'zitadel',
];
expect($view)
->toContain("asset('svgs/' . \$provider . '.svg')")
->toContain('mask:')
->toContain('bg-current')
->not->toContain('<x-reicon name="keys" class="menu-item-icon" />');
foreach ($providers as $provider) {
$svg = public_path("svgs/{$provider}.svg");
expect($svg)->toBeFile();
$contents = file_get_contents($svg);
expect($contents)
->toContain('<svg')
->toContain('<path')
->not->toMatch('/fill="#[0-9a-fA-F]{3,8}"/')
->not->toContain('fill="#e24329"')
->not->toContain('fill="#4285F4"');
}
// Keep GitLab at the natural simple-icons 24x24 mark (no optical scale).
expect(file_get_contents(public_path('svgs/gitlab.svg')))
->not->toContain('scale(');
});
@@ -0,0 +1,16 @@
<?php
use Illuminate\Support\Facades\Blade;
it('renders section helper copy as a visible description instead of an info icon', function () {
$html = Blade::render(<<<'BLADE'
<x-application.settings-section title="Primary server" helper="The server and network used by this resource.">
Content
</x-application.settings-section>
BLADE);
expect($html)
->toContain('Primary server')
->toContain('The server and network used by this resource.')
->not->toContain('aria-label="More information"');
});
@@ -25,3 +25,11 @@ it('does not animate navbar padding when restoring collapsed state', function ()
->not->toContain('items-start gap-3 motion-safe:transition-all')
->not->toContain('overflow-hidden motion-safe:transition-all');
});
it('draws a single border between the desktop sidebar and main content', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbar)->toContain('border-r border-neutral-200')
->and($layout)->not->toContain('lg:border-l border-neutral-200');
});
+5 -5
View File
@@ -12,7 +12,7 @@ it('renders application and database statuses as shared status badges', function
])->render();
expect($html)
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->toContain('inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border')
->toContain($expectedText)
->not->toContain('badge-success')
->not->toContain('badge-warning')
@@ -21,7 +21,7 @@ it('renders application and database statuses as shared status badges', function
'running healthy' => ['running:healthy', 'Running (healthy)'],
'starting unknown' => ['starting:unknown', 'Starting (unknown)'],
'degraded unhealthy' => ['degraded:unhealthy', 'Degraded (unhealthy)'],
'exited unhealthy' => ['exited:unhealthy', 'Exited'],
'exited unhealthy' => ['exited:unhealthy', 'Stopped'],
]);
it('renders service container statuses as shared status badges', function () {
@@ -31,7 +31,7 @@ it('renders service container statuses as shared status badges', function () {
])->render();
expect($html)
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->toContain('inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border')
->toContain('Running (healthy)')
->not->toContain('badge-success');
});
@@ -68,7 +68,7 @@ it('renders health warning helpers without increasing the badge row height', fun
expect($html)
->toContain($expectedText)
->toContain('class="flex items-center gap-1 leading-none"')
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->toContain('inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border')
->not->toContain('<svg');
expect($runningStatus)
@@ -108,7 +108,7 @@ it('renders restart counts as warning badges', function () {
expect($html)
->toContain('9x restarts')
->toContain('border-yellow-300 bg-yellow-50')
->toContain('bg-warning')
->not->toContain('(9x restarts)');
expect($statusIndex)
+48 -4
View File
@@ -34,6 +34,7 @@ it('labels console themes without a Shadow\'s prefix', function () {
foreach ([$terminalView, $consoleView] as $view) {
expect($view)
->toContain("['key' => 'system', 'name' => 'System'")
->toContain("'name' => 'Tropical Storm'")
->toContain("'name' => 'Blur Black'")
->toContain("'name' => 'Cosmic Purple'")
@@ -41,7 +42,50 @@ it('labels console themes without a Shadow\'s prefix', function () {
}
});
it('shows console unavailable without the terminal shell wrapper', function () {
it('defaults to a system console theme that follows the page color mode', function () {
$terminalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$appCss = file_get_contents(resource_path('css/app.css'));
foreach ([$terminalView, $consoleView] as $view) {
expect($view)
->toContain("consoleTheme: 'system'")
->toContain("? savedTheme : 'system'");
}
expect($terminalClient)
->toContain("'system': createSystemTerminalTheme()")
->toContain('new MutationObserver')
->toContain("attributeFilter: ['class', 'data-theme']")
->and($appCss)
->toContain('[data-console-theme="system"]')
->toContain('html.dark .application-console-shell[data-console-theme="system"]');
});
it('uses the page color mode for the console theme selector', function () {
$terminalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
foreach ([$terminalView, $consoleView] as $view) {
expect($view)
->toContain('console-theme-selector')
->toContain('border-neutral-200 bg-white')
->toContain('dark:bg-[#111113]')
->toContain('text-neutral-600 transition-colors')
->toContain('dark:text-white/65');
}
$appCss = file_get_contents(resource_path('css/app.css'));
expect($appCss)
->toContain('.console-theme-selector')
->toContain('scrollbar-color: rgb(161 161 170) transparent')
->toContain('html.dark .console-theme-selector')
->toContain('scrollbar-color: rgb(255 255 255 / 0.28) transparent');
});
it('shows terminal unavailable without the terminal shell wrapper', function () {
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$unavailableBranch = str($consoleView)
@@ -52,14 +96,14 @@ it('shows console unavailable without the terminal shell wrapper', function () {
expect($consoleView)
->toContain('$consoleUnavailable')
->toContain('@if ($consoleUnavailable)')
->toContain('title="Console unavailable"')
->toContain('title="Terminal unavailable"')
->toContain('icon-name="browser-terminal"')
// Empty state must not nest inside the themed terminal chrome.
->not->toContain('No running containers');
expect($unavailableBranch)
->toContain('<x-empty')
->toContain('title="Console unavailable"')
->toContain('title="Terminal unavailable"')
->not->toContain('application-console-shell')
->not->toContain('application-console-header')
->not->toContain('Choose terminal theme');
@@ -78,7 +122,7 @@ it('shows runtime logs unavailable without log viewer chrome', function () {
->toContain('title="Runtime logs unavailable"')
->toContain('icon-name="file-content"')
->toContain('class="mt-4 w-full lg:mt-3"')
->not->toContain('max-w-[1180px]')
->toContain('application-settings-workspace')
->not->toContain('title="No running containers"')
->not->toContain('application-settings-form');
+15
View File
@@ -18,3 +18,18 @@ it('still shows the user name and email inside the dropdown panel', function ()
->toContain('truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div>')
->toContain('{{ $userEmail }}');
});
it('changes appearance from a submenu instead of navigating to a separate page', function () {
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($menu)
->toContain('appearanceOpen: false')
->toContain('@click.outside="open = false; appearanceOpen = false"')
->toContain("theme: localStorage.getItem('theme') || 'dark'")
->toContain('setTheme(type)')
->toContain("['value' => 'light', 'label' => 'Light'")
->toContain("['value' => 'system', 'label' => 'System'")
->toContain("['value' => 'dark', 'label' => 'Dark'")
->toContain('hover:bg-neutral-200 hover:text-neutral-950')
->not->toContain("route('profile.appearance')");
});