mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 04:18:34 +00:00
feat(ui): unify status summaries, tooltips, and resource headings
Add shared status-summary and icon-tooltip components and wire them into application, service, and database headings, breadcrumbs, and previews. Badge production and PR domain links, prefer helper popups below the trigger, keep full wire targets for button loading, and cover the UI with feature tests.
This commit is contained in:
1 parent
6b41899d5b
commit
262ad8981a
35 files changed
+749
-265
No files matched your search
@@ -1184,7 +1184,7 @@ it('does not show a suggested row when both www variants are configured', functi
|
||||
expect(collect($component->get('domainRows'))->where('is_suggested', true))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('groups compose domains by service and shows empty services', function () {
|
||||
it('groups compose domains by service and hides empty services', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
@@ -1207,8 +1207,30 @@ it('groups compose domains by service and shows empty services', function () {
|
||||
$component
|
||||
->assertSee('web')
|
||||
->assertSee('https://web.example.com')
|
||||
->assertSee('api')
|
||||
->assertSee('No domains for this service yet');
|
||||
->assertDontSee('No domains for this service');
|
||||
});
|
||||
|
||||
it('uses the compact service domains layout for compose applications', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
|
||||
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
|
||||
->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
|
||||
->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }}"')
|
||||
->not->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }},setServiceRedirect"')
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
it('provides client-side search for compose service domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-model="domainSearch"')
|
||||
->toContain('placeholder="Search services or domains"')
|
||||
->toContain('x-show="matchesDomainSearch(')
|
||||
->toContain('title="No domains found"')
|
||||
->toContain('hasDomainSearchResults(');
|
||||
});
|
||||
|
||||
it('exposes the domains route in the application configuration menu', function () {
|
||||
|
||||
@@ -12,7 +12,10 @@ it('shows internal Docker access details in application general settings', funct
|
||||
->toContain('<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
|
||||
->toContain("Str::plural('domain', \$domainCount)")
|
||||
->toContain('Domains, DNS checks, and redirect settings')
|
||||
->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
|
||||
->toContain('class="icon-button shrink-0 self-start sm:self-auto"')
|
||||
->toContain('aria-label="Manage domains"')
|
||||
->toContain('<x-reicon name="settings" class="size-4" />')
|
||||
->not->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
|
||||
->toContain('<livewire:project.application.internal-access')
|
||||
->not->toContain('wire:init="loadCurrentInternalHostname"')
|
||||
->and($internalAccessSettings)
|
||||
|
||||
@@ -13,5 +13,6 @@ it('keeps editable standalone database storage actions in the table action colum
|
||||
->toContain('@if ($showBackupAction)')
|
||||
->toContain('title="New Scheduled Backup"')
|
||||
->toContain('<livewire:project.database.create-scheduled-backup :database="$resource"')
|
||||
->toContain('Backup');
|
||||
->toContain('aria-label="Configure backup"')
|
||||
->toContain('<x-reicon name="database" class="size-4" />');
|
||||
});
|
||||
@@ -30,13 +30,13 @@ it('uses an explicit wire target for submit buttons without wire click', functio
|
||||
->toContain('Validate and add');
|
||||
});
|
||||
|
||||
it('strips method arguments when deriving the loading target', function () {
|
||||
it('keeps method arguments when deriving the loading target', function () {
|
||||
$html = Blade::render(
|
||||
'<x-forms.button wire:click="setPrivateKey(42)">Use this key</x-forms.button>'
|
||||
);
|
||||
|
||||
expect($html)
|
||||
->toContain('wire:target="setPrivateKey"')
|
||||
->toContain('wire:target="setPrivateKey(42)"')
|
||||
->toContain('wire:loading.attr="disabled"')
|
||||
->toContain('wire:loading.class="is-loading"');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
it('uses the custom tooltip for icon actions across layouts', function () {
|
||||
$baseLayout = file_get_contents(resource_path('views/layouts/base.blade.php'));
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($baseLayout)->toContain('<x-icon-tooltip />')
|
||||
->and($tooltip)
|
||||
->toContain("closest('button, a, [data-tooltip]')")
|
||||
->toContain("target.querySelector('svg')")
|
||||
->toContain("target.removeAttribute('title')")
|
||||
->toContain('role="tooltip"')
|
||||
->toContain('aria-label')
|
||||
->toContain('fixed z-[100]');
|
||||
});
|
||||
@@ -59,12 +59,23 @@ test('helper popup stays in its alpine scope during livewire morphs', function (
|
||||
->not->toContain('x-teleport');
|
||||
});
|
||||
|
||||
test('helper popup prefers a position above its info button', function () {
|
||||
test('helper triggers do not create stacking contexts above open tooltips', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('let top = triggerRect.top - popupRect.height - padding;')
|
||||
->toContain('top = triggerRect.bottom + padding;');
|
||||
->toContain("'class' => 'relative inline-block align-middle'")
|
||||
->toContain("'info-helper relative inline-flex")
|
||||
->not->toContain("'class' => 'relative z-10 inline-block align-middle'")
|
||||
->not->toContain("'info-helper relative z-10 inline-flex");
|
||||
});
|
||||
|
||||
test('helper popup prefers a position below its trigger and falls back above', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('let top = triggerRect.bottom + padding;')
|
||||
->toContain('let left = triggerRect.right - popupRect.width;')
|
||||
->toContain('top = triggerRect.top - popupRect.height - padding;');
|
||||
});
|
||||
|
||||
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('aggregates preview container and health check status', function () {
|
||||
$html = Blade::render('<x-status-summary status="running:unknown" title="Preview status" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('Preview status')
|
||||
->toContain('Container')
|
||||
->toContain('Running (no health check)')
|
||||
->toContain('Health check')
|
||||
->toContain('Not configured')
|
||||
->toContain('aria-label="About unconfigured health checks"')
|
||||
->toContain('Traffic can still be routed to the container')
|
||||
->toContain('aria-haspopup="menu"');
|
||||
});
|
||||
|
||||
it('uses the aggregated preview status in the previews list', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-status-summary :status="data_get($preview, \'status\')" title="Preview status" />')
|
||||
->not->toContain('<x-status.running :status="data_get($preview, \'status\')" />');
|
||||
});
|
||||
|
||||
it('uses the aggregated application status beside the top breadcrumbs', function () {
|
||||
$status = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
|
||||
|
||||
expect($status)
|
||||
->toContain('<x-status-summary :status="$application->status" />')
|
||||
->not->toContain('$statusLabel');
|
||||
});
|
||||
|
||||
it('uses the aggregated status badge for databases and services', function () {
|
||||
$databaseStatus = file_get_contents(resource_path('views/livewire/project/database/status.blade.php'));
|
||||
$serviceStatus = file_get_contents(resource_path('views/livewire/project/service/status.blade.php'));
|
||||
|
||||
expect($databaseStatus)
|
||||
->toContain('<x-status-summary :status="$database->status" title="Database status" />')
|
||||
->and($serviceStatus)
|
||||
->toContain('<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />');
|
||||
});
|
||||
|
||||
it('groups preview deployment actions in a dropdown', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('title="Preview actions"')
|
||||
->toContain('preview-stop-trigger-{{ data_get($preview, \'pull_request_id\') }}')
|
||||
->toContain('preview-delete-trigger-{{ data_get($preview, \'pull_request_id\') }}')
|
||||
->not->toContain('<x-slot:customButton>');
|
||||
});
|
||||
|
||||
it('marks external preview links and groups logs in a dropdown', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('title="Open preview in a new tab"')
|
||||
->toContain('title="Open pull request in a new tab"')
|
||||
->toContain('<x-reicon name="external-link"')
|
||||
->toContain('title="Preview logs"')
|
||||
->toContain('Deployment logs')
|
||||
->toContain('Runtime logs')
|
||||
->not->toContain('Application logs');
|
||||
});
|
||||
|
||||
it('places links and logs dropdowns beside preview actions', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
|
||||
$controls = str($view)->after('id="preview-header-controls')->before('<div class="hidden" aria-hidden="true">')->toString();
|
||||
|
||||
expect($controls)
|
||||
->toContain('title="Preview links"')
|
||||
->toContain('title="Preview logs"')
|
||||
->toContain('title="Preview actions"');
|
||||
});
|
||||
@@ -170,13 +170,36 @@ it('centers the rollback image loading state across the card', function () {
|
||||
->toContain('flex items-center justify-center');
|
||||
});
|
||||
|
||||
it('shows pull request loading feedback inside its settings card', function () {
|
||||
it('shows pull request loading feedback only on the action button', 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…"');
|
||||
->toContain('wire:click="load_prs"')
|
||||
->not->toContain('wire:loading.remove wire:target="load_prs"')
|
||||
->not->toContain('wire:loading wire:target="load_prs"')
|
||||
->not->toContain('Loading pull requests…');
|
||||
});
|
||||
|
||||
it('shows loading feedback while a service deployment starts', function () {
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
|
||||
expect($service)
|
||||
->toContain('deploying: false')
|
||||
->toContain('@service-deploy-finished.window="deploying = false"')
|
||||
->toContain('<x-loading-on-button x-show="deploying"')
|
||||
->toContain("window.dispatchEvent(new CustomEvent('service-deploy-finished'))");
|
||||
});
|
||||
|
||||
it('uses neutral icons for non-destructive resource actions', function () {
|
||||
foreach (['application', 'service', 'database'] as $resource) {
|
||||
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
|
||||
|
||||
expect($heading)
|
||||
->not->toContain('class="size-3.5 text-orange-500')
|
||||
->not->toContain('class="size-3.5 text-warning"')
|
||||
->not->toContain('class="size-4 text-warning"')
|
||||
->toContain('name="stop" class="size-3.5 text-error"');
|
||||
}
|
||||
});
|
||||
|
||||
it('allows the shared empty state to control the storage backups background', function () {
|
||||
@@ -327,6 +350,15 @@ it('shows an icon in application and service Links controls', function () {
|
||||
->and($serviceLinks)->not->toContain('<x-external-link');
|
||||
});
|
||||
|
||||
it('visually distinguishes production and pull request application links', function () {
|
||||
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
|
||||
|
||||
expect($links)
|
||||
->toContain('Production')
|
||||
->toContain('PR #{{ data_get($preview, \'pull_request_id\') }}')
|
||||
->not->toContain("PR{{ data_get(\$preview, 'pull_request_id') }} |");
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
@@ -13,16 +13,14 @@ it('collapses server subsystem badges into one status summary', function () {
|
||||
->toContain("['Attention required', 'warning']")
|
||||
->toContain("['Ready', 'success']")
|
||||
->toContain('System status')
|
||||
->toContain('Refresh status')
|
||||
->toContain('aria-label="Refresh status"')
|
||||
->toContain('<x-reicon name="chevron-down"')
|
||||
->toContain(":class=\"open && 'rotate-180'\"")
|
||||
->toContain('wire:click="checkProxyStatus"')
|
||||
->toContain('wire:loading.remove wire:target="checkProxyStatus"')
|
||||
->toContain('wire:loading.flex wire:target="checkProxyStatus"')
|
||||
->toContain('Refreshing status')
|
||||
->toContain('<x-loading compact />')
|
||||
->toContain('name="refresh" class="size-2.5 opacity-70"')
|
||||
->toContain('min-h-0! h-7! gap-1.5! px-2! py-1! text-[11px]')
|
||||
->toContain('wire:loading.class="animate-spin" wire:target="checkProxyStatus"')
|
||||
->toContain('name="refresh" class="size-3"')
|
||||
->not->toContain('Refreshing status')
|
||||
->not->toContain('<x-loading compact />')
|
||||
->toContain("href=\"{{ route('server.proxy', ['server_uuid' => \$server->uuid]) }}\"")
|
||||
->toContain("href=\"{{ route('server.sentinel', ['server_uuid' => \$server->uuid]) }}\"")
|
||||
->toContain('@click="open = false" role="menuitem"');
|
||||
|
||||
@@ -28,3 +28,16 @@ it('directs compose application domain management to the parent service', functi
|
||||
->toContain('Manage domains')
|
||||
->not->toContain('<x-forms.domain-chips model="fqdn" label="Domains"');
|
||||
});
|
||||
|
||||
it('aligns compose resource columns and uses icon actions', function () {
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
|
||||
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
|
||||
$columns = 'sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8rem_5rem]';
|
||||
|
||||
expect($configuration)->toContain($columns)
|
||||
->and($resourceCard)->toContain($columns)
|
||||
->toContain('aria-label="Resource settings"')
|
||||
->toContain('aria-label="Database backups"')
|
||||
->not->toContain('>Settings</a>')
|
||||
->not->toContain('>Backups</a>');
|
||||
});
|
||||
@@ -65,6 +65,15 @@ it('shows the database sidebar on backup pages', function () {
|
||||
->toContain("str(\$currentRoute)->startsWith('project.database.backup')");
|
||||
});
|
||||
|
||||
it('shows the service sidebar on the storage backups page', function () {
|
||||
$backups = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
|
||||
|
||||
expect($backups)
|
||||
->toContain('<x-service.configuration-sidebar :service="$service"')
|
||||
->toContain('current-route="project.service.volume-backups.index"')
|
||||
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]');
|
||||
});
|
||||
|
||||
it('uses a distinct backup icon across resource sidebars', function () {
|
||||
$sidebars = [
|
||||
resource_path('views/components/application/configuration-sidebar.blade.php'),
|
||||
|
||||
@@ -263,6 +263,17 @@ it('keeps a stable key for the rendered domain list', function () {
|
||||
->not->toContain('md5(serialize($domainRows))');
|
||||
});
|
||||
|
||||
it('provides client-side search for service domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-model="domainSearch"')
|
||||
->toContain('placeholder="Search services or domains"')
|
||||
->toContain('x-show="matchesDomainSearch(')
|
||||
->toContain('title="No domains found"')
|
||||
->toContain('hasDomainSearchResults(');
|
||||
});
|
||||
|
||||
it('does not duplicate the service name as a badge in the domain cell', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ it('uses bordered status badges in the top breadcrumb', function () {
|
||||
$applicationStatus = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
|
||||
$borderedBadgeClasses = 'rounded-full border border-neutral-200 bg-neutral-100';
|
||||
|
||||
expect(substr_count($breadcrumb.$applicationStatus, $borderedBadgeClasses))->toBe(4)
|
||||
expect(substr_count($breadcrumb.$applicationStatus, $borderedBadgeClasses))->toBe(3)
|
||||
->and($applicationStatus)->toContain('<x-status-summary')
|
||||
->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user