mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 18:18:29 +00:00
feat(ui): polish domains UX and dark-mode loading accents
Group service domain tables with inline redirect controls, live-save redirects without full refreshes, and only auto-pair www hosts when a www redirect is active. Align helper tooltips, server-timing HUD, and loading spinners with dark-theme accents; default theme-color to dark.
This commit is contained in:
1 parent
232b999cc9
commit
bb83c1e3be
19 files changed
+404
-176
No files matched your search
@@ -131,6 +131,20 @@ test('Server-Timing HUD docks into navbar slots and floats only as fallback', fu
|
||||
->toContain('compactSummary');
|
||||
});
|
||||
|
||||
test('Server-Timing HUD follows the application color mode', function () {
|
||||
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
|
||||
|
||||
expect($hud)
|
||||
->toContain('#server-timing-hud {')
|
||||
->toContain('html.dark #server-timing-hud {')
|
||||
->toContain('--sth-background: rgba(255, 255, 255, .96)')
|
||||
->toContain('--sth-background: rgba(16, 16, 16, .96)')
|
||||
->toContain('var(--sth-text)')
|
||||
->toContain('var(--sth-border)')
|
||||
->toContain('[data-sth-log]::-webkit-scrollbar-thumb')
|
||||
->toContain('scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track)');
|
||||
});
|
||||
|
||||
test('does not inject HUD into non-HTML or fragment responses', function () {
|
||||
Config::set('app.server_timing', true);
|
||||
|
||||
|
||||
@@ -12,3 +12,11 @@ test('breadcrumb switchers indicate the current item with background only', func
|
||||
->and($views->every(fn (string $view): bool => str_contains($view, 'bg-neutral-100')))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
test('teleported resource breadcrumbs use the same spacing as application breadcrumbs', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
|
||||
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
test('new users default to dark mode', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('<html data-theme="dark"')
|
||||
->toContain("const t = localStorage.theme || 'dark';")
|
||||
->toContain("localStorage.theme = 'dark';")
|
||||
->toContain('<meta name="theme-color" content="#101010" id="theme-color-meta" />');
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
test('docker cleanup execution output uses light and dark theme surfaces', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('bg-white text-neutral-700')
|
||||
->toContain('dark:bg-neutral-950 dark:text-neutral-300');
|
||||
});
|
||||
|
||||
test('docker cleanup executions show a loading state while opening an execution', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:loading.attr="disabled"')
|
||||
->toContain('wire:target="selectExecution({{ data_get($execution, \'id\') }})"')
|
||||
->toContain('wire:loading.remove')
|
||||
->toContain('<x-loading wire:loading');
|
||||
});
|
||||
@@ -44,12 +44,27 @@ test('helper popup remains open while moving from the trigger into interactive c
|
||||
->toContain('@mouseleave="hide()"');
|
||||
});
|
||||
|
||||
test('clicking a helper does not pin its popup open', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('@click.prevent.stop="show()"')
|
||||
->not->toContain('pinned');
|
||||
});
|
||||
|
||||
test('helper popup stays in its alpine scope during livewire morphs', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->not->toContain('x-teleport');
|
||||
});
|
||||
|
||||
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('closeWhenFocusLeaves()')
|
||||
->toContain('@focus="show(false)"')
|
||||
->toContain('@focus="show()"')
|
||||
->toContain('@blur="closeWhenFocusLeaves()"')
|
||||
->toContain('role="tooltip"')
|
||||
->toContain(':aria-describedby="open ? $id(\'helper-popup\') : null"')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
test('livewire loading indicators use the shared coollabs purple', function () {
|
||||
test('loading indicators use yellow throughout dark mode', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
|
||||
$pageLoading = file_get_contents(resource_path('views/components/page-loading.blade.php'));
|
||||
$views = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
|
||||
@@ -11,10 +12,14 @@ test('livewire loading indicators use the shared coollabs purple', function () {
|
||||
|
||||
expect($utilities)
|
||||
->toContain('@utility loading-indicator')
|
||||
->toContain('@apply text-coollabs dark:text-coollabs-100;')
|
||||
->toContain('@apply text-coollabs dark:text-warning;')
|
||||
->and($loading)->toContain('loading-indicator')
|
||||
->and($pageLoading)->toContain('loading-indicator')
|
||||
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-warning|dark:text-warning[^"\n]*animate-spin/');
|
||||
->and($appCss)->toContain('.dark .animate-spin')
|
||||
->toContain('color: var(--color-warning) !important;')
|
||||
->toContain('.dark #nprogress .bar')
|
||||
->toContain('background: var(--color-warning) !important;')
|
||||
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-coollabs|dark:text-coollabs[^"\n]*animate-spin/');
|
||||
});
|
||||
|
||||
test('livewire navigation progress bar uses coollabs purple', function () {
|
||||
|
||||
@@ -90,12 +90,26 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists domains grouped by service application on the stack domains page', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
it('groups configured domains with their service redirect and excludes services without domains', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com,https://admin.example.com',
|
||||
]);
|
||||
|
||||
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSuccessful()
|
||||
->assertSee('API')
|
||||
->assertSee('https://api.example.com')
|
||||
->assertSee('Web');
|
||||
->assertSee('https://admin.example.com')
|
||||
->html();
|
||||
|
||||
expect($html)
|
||||
->toContain("service-domain-group-{$this->apiApp->id}")
|
||||
->toContain("service-domain-redirect-{$this->apiApp->id}")
|
||||
->toContain("wire:model.change=\"serviceRedirects.{$this->apiApp->id}\"")
|
||||
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
|
||||
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
|
||||
->not->toContain("service-domain-group-{$this->webApp->id}")
|
||||
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
|
||||
});
|
||||
|
||||
it('shows dns entries control next to Add', function () {
|
||||
@@ -139,14 +153,18 @@ it('lists dns entries for service hosts that still need dns', function () {
|
||||
->not->toContain('web.example.com');
|
||||
});
|
||||
|
||||
it('does not persist service redirect until Set Direction is called', function () {
|
||||
it('persists a service redirect when its dropdown changes', function () {
|
||||
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set("serviceRedirects.{$this->webApp->id}", 'www');
|
||||
->set("serviceRedirects.{$this->webApp->id}", 'www')
|
||||
->assertDispatched('success')
|
||||
->assertSee('https://www.web.example.com');
|
||||
|
||||
expect($this->webApp->fresh()->redirect)->toBe('both')
|
||||
->and($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
|
||||
expect($this->webApp->fresh()->redirect)->toBe('www')
|
||||
->and(explode(',', (string) $this->webApp->fresh()->fqdn))
|
||||
->toContain('https://web.example.com')
|
||||
->toContain('https://www.web.example.com');
|
||||
});
|
||||
|
||||
it('sets redirect direction per service application without changing other apps', function () {
|
||||
@@ -166,6 +184,18 @@ it('sets redirect direction per service application without changing other apps'
|
||||
->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it('saves the explicitly selected service redirect value', function () {
|
||||
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('updateServiceRedirect', $this->webApp->id, 'www')
|
||||
->assertDispatched('success')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
|
||||
->assertSee('https://www.web.example.com');
|
||||
|
||||
expect($this->webApp->fresh()->redirect)->toBe('www');
|
||||
});
|
||||
|
||||
it('auto-adds missing non-www pair for a service application redirect', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://www.api.example.com',
|
||||
@@ -185,7 +215,7 @@ it('auto-adds missing non-www pair for a service application redirect', function
|
||||
->toContain('https://api.example.com');
|
||||
});
|
||||
|
||||
it('auto-adds the suggested www pair when adding a service domain with both directions', function () {
|
||||
it('adds only the entered domain when redirects allow both directions', function () {
|
||||
$this->webApp->update(['redirect' => 'both']);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
@@ -196,9 +226,7 @@ it('auto-adds the suggested www pair when adding a service domain with both dire
|
||||
->assertDispatched('success')
|
||||
->assertSee('DNS skipped');
|
||||
|
||||
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
|
||||
->toContain('https://web.example.com')
|
||||
->toContain('https://www.web.example.com');
|
||||
expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
|
||||
});
|
||||
|
||||
it('adds a domain to a selected service application', function () {
|
||||
@@ -212,24 +240,26 @@ it('adds a domain to a selected service application', function () {
|
||||
->assertSee('https://web.example.com');
|
||||
|
||||
$this->webApp->refresh();
|
||||
expect(explode(',', (string) $this->webApp->fqdn))
|
||||
->toBe(['https://web.example.com', 'https://www.web.example.com']);
|
||||
expect($this->webApp->fqdn)->toBe('https://web.example.com');
|
||||
|
||||
$dnsStatuses = $this->webApp->domain_dns_statuses;
|
||||
|
||||
expect($dnsStatuses)
|
||||
->toHaveKey('https://web.example.com')
|
||||
->toHaveKey('https://www.web.example.com')
|
||||
->not->toHaveKey('https://www.web.example.com')
|
||||
->and($dnsStatuses['https://web.example.com']['status'])
|
||||
->toBe('skipped')
|
||||
->and($dnsStatuses['https://web.example.com']['checked_at'])
|
||||
->not->toBeNull();
|
||||
});
|
||||
|
||||
it('replaces the rendered domain list when its rows change', function () {
|
||||
it('keeps a stable key for the rendered domain list', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
|
||||
expect($view)->toContain('wire:key="service-domains-list-{{ md5(serialize($domainRows)) }}"');
|
||||
expect($view)
|
||||
->toContain('wire:key="service-domains-list"')
|
||||
->toContain('wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}"')
|
||||
->not->toContain('md5(serialize($domainRows))');
|
||||
});
|
||||
|
||||
it('does not duplicate the service name as a badge in the domain cell', function () {
|
||||
@@ -279,7 +309,8 @@ it('prunes dns status when a service domain is removed', function () {
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('removeDomain', 0)
|
||||
->assertDispatched('success');
|
||||
->assertDispatched('success')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->doesntContain('https://api.example.com'));
|
||||
|
||||
expect($this->apiApp->fresh()->domain_dns_statuses)->toBeNull();
|
||||
});
|
||||
@@ -334,7 +365,7 @@ it('does not restore stale dns status when a removed service domain is re-added'
|
||||
$this->apiApp->refresh();
|
||||
|
||||
expect(explode(',', (string) $this->apiApp->fqdn))
|
||||
->toBe(['https://api.example.com', 'https://www.api.example.com'])
|
||||
->toBe(['https://api.example.com'])
|
||||
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['status'] ?? null)->toBe('skipped')
|
||||
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['message'] ?? null)->not->toBe('Stale DNS result.');
|
||||
});
|
||||
@@ -371,7 +402,7 @@ YAML,
|
||||
->assertDispatched('success');
|
||||
|
||||
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
|
||||
->toBe(['https://api.example.com', 'https://www.api.example.com']);
|
||||
->toBe(['https://api.example.com']);
|
||||
});
|
||||
|
||||
it('loads persisted dns status for service applications', function () {
|
||||
|
||||
@@ -60,6 +60,9 @@ it('defaults to a system console theme that follows the page color mode', functi
|
||||
->toContain("attributeFilter: ['class', 'data-theme']")
|
||||
->and($appCss)
|
||||
->toContain('[data-console-theme="system"]')
|
||||
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"]')
|
||||
->toContain('--console-theme-border: #d4d4d8')
|
||||
->toContain('background: transparent')
|
||||
->toContain('html.dark .application-console-shell[data-console-theme="system"]');
|
||||
});
|
||||
|
||||
|
||||
@@ -94,6 +94,60 @@ it('opens service environment variables domains and storages pages', function ()
|
||||
->screenshot(filename: 'service-storages');
|
||||
});
|
||||
|
||||
it('renders an automatically added redirect counterpart without reloading', function () {
|
||||
$this->serviceApplication->update([
|
||||
'fqdn' => 'https://web.example.com',
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$base = serviceConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->service
|
||||
);
|
||||
|
||||
$page = visit("{$base}/domains")
|
||||
->assertSee('https://web.example.com');
|
||||
|
||||
$selector = '#service-domain-redirect-'.$this->serviceApplication->id;
|
||||
$page->select($selector, 'www')
|
||||
->wait(3);
|
||||
|
||||
$this->serviceApplication->refresh();
|
||||
expect($this->serviceApplication->redirect)->toBe('www');
|
||||
|
||||
$page->assertSee('https://www.web.example.com')
|
||||
->screenshot(filename: 'service-domain-redirect-counterpart');
|
||||
|
||||
$page->script(<<<'JS'
|
||||
(() => {
|
||||
const domain = document.querySelector('a[title="https://www.web.example.com"]');
|
||||
domain.closest('.env-table-item').querySelector('[aria-label="Remove domain"]').click();
|
||||
})()
|
||||
JS);
|
||||
|
||||
$page->wait(0.5)
|
||||
->script(<<<'JS'
|
||||
(() => {
|
||||
const button = [...document.querySelectorAll('button')]
|
||||
.find((candidate) => candidate.offsetParent && candidate.textContent.trim() === 'Remove domain');
|
||||
button.click();
|
||||
})()
|
||||
JS);
|
||||
|
||||
$page->wait(3);
|
||||
|
||||
$configuredCounterparts = $page->script(
|
||||
'document.querySelectorAll(\'a[title="https://www.web.example.com"]\').length'
|
||||
);
|
||||
|
||||
expect($configuredCounterparts)->toBe(0);
|
||||
|
||||
$page->screenshot(filename: 'service-domain-remove-without-reload');
|
||||
});
|
||||
|
||||
it('opens compose stack application general settings', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
|
||||
Reference in new issue
Block a user