mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +00:00
fix(ui): stabilize domain controls and unify resource navigation
Preserve optimistic listbox selections during Livewire saves, refresh service domains after updates, and align application, service, and server navigation layouts.
This commit is contained in:
1 parent
3f47d41880
commit
41b3ed95aa
21 files changed
+464
-954
No files matched your search
@@ -108,12 +108,39 @@ it('lists existing domains as individual rows', function () {
|
||||
->assertSee('https://example.com')
|
||||
->assertSee('https://www.example.com')
|
||||
->assertSee('https://example.com/favicon.ico', false)
|
||||
->assertSee('class="relative size-4 shrink-0"', false)
|
||||
->assertSee('domain-favicon-fallback', false)
|
||||
->assertSee('class="invisible absolute inset-0 size-4 rounded-sm"', false)
|
||||
->assertSee('$el.previousElementSibling.classList.add(\'hidden\')', false)
|
||||
->assertSee('x-on:error="$el.remove()"', false)
|
||||
->assertSee('class="min-w-0 flex-1 text-[13px]', false)
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2);
|
||||
});
|
||||
|
||||
it('shows one redirect direction control in each compose service header', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'api' => [
|
||||
'domain' => 'https://api.example.com,https://www.api.example.com',
|
||||
'redirect' => 'www',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSuccessful()
|
||||
->assertSee('api')
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1)
|
||||
->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0)
|
||||
->and(substr_count($html, 'domain-direction-service-api'))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows dns entries control next to Add', function () {
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSuccessful()
|
||||
@@ -1225,15 +1252,25 @@ it('uses the compact service domains layout for compose applications', function
|
||||
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
|
||||
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
|
||||
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
|
||||
->toContain('class="data-table-header domains-table-grid"')
|
||||
->toContain('class="data-table-header domains-table-grid-service"')
|
||||
->toContain('<span>Direction</span>')
|
||||
->toContain('<span>Search engine indexing</span>')
|
||||
->not->toContain('<span>Last checked</span>')
|
||||
->not->toContain('id="edit-domain-direction"')
|
||||
->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"')
|
||||
->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
|
||||
->toContain('id="domain-direction-service-{{ $redirectWireKey }}"')
|
||||
->toContain('onChange="updateServiceRedirect"')
|
||||
->toContain("'showDirectionControl' => false")
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
it('does not render a last checked column in the domains table', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
||||
expect($view)->not->toContain('<span>Last checked</span>')
|
||||
->and($row)->not->toContain('$checkedAt');
|
||||
});
|
||||
|
||||
it('uses compact labeled domain cards on mobile', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
@@ -1407,6 +1444,7 @@ it('updates search engine indexing from the domains view', function () {
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
|
||||
@@ -88,6 +88,31 @@ test('listbox forwards dynamic disabled state to its trigger', function () {
|
||||
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
|
||||
});
|
||||
|
||||
test('listbox waits for change handlers and prevents overlapping selections', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain('saving: false')
|
||||
->toContain('async choose(option)')
|
||||
->toContain('await this.$wire.')
|
||||
->toContain('if (this.saving || option.disabled) return;')
|
||||
->toContain("'pointer-events-none opacity-70': saving");
|
||||
});
|
||||
|
||||
test('listbox does not send a second live entangle request when using a change handler', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)->toContain('@elseif ($live && ! $onChange) @entangle($id).live');
|
||||
});
|
||||
|
||||
test('listbox can preserve its client value across Livewire morphs', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain("'preserveValue' => false")
|
||||
->toContain('@if ($preserveValue) wire:ignore @endif');
|
||||
});
|
||||
|
||||
test('notification event multiselect truncates long selected summaries', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[
|
||||
|
||||
@@ -113,17 +113,27 @@ 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 application links next to advanced actions on the right', function () {
|
||||
it('keeps advanced operations in a separated section at the bottom of actions menus', 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'));
|
||||
|
||||
$desktop = str($application)->after('resource-heading-actions flex')->toString();
|
||||
$advancedPosition = strpos($desktop, '<x-applications.advanced');
|
||||
$linksPosition = strpos($desktop, '<x-applications.links');
|
||||
$applicationDesktop = str($application)->after('resource-heading-actions flex')->toString();
|
||||
$serviceDesktop = str($service)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($advancedPosition)->not->toBeFalse()
|
||||
->and($linksPosition)->not->toBeFalse()
|
||||
->and($linksPosition)->toBeGreaterThan($advancedPosition)
|
||||
expect($applicationDesktop)
|
||||
->not->toContain('<x-applications.advanced')
|
||||
->toContain('application-desktop-actions')
|
||||
->toContain('role="separator"')
|
||||
->toContain('Force deploy without cache')
|
||||
->and($serviceDesktop)
|
||||
->not->toContain('<x-services.advanced')
|
||||
->toContain('service-desktop-actions')
|
||||
->toContain('role="separator"')
|
||||
->toContain('Pull Latest Images & Restart')
|
||||
->toContain('Force Restart')
|
||||
->toContain('Force Deploy')
|
||||
->toContain('Force Cleanup Containers')
|
||||
->and($links)->toContain("'right-0! left-auto! min-w-60! max-w-96!' => !\$fullWidth")
|
||||
->and($links)->toContain('listbox-option justify-start! gap-2.5!')
|
||||
->and($links)->not->toContain('md:left-0 md:right-auto');
|
||||
@@ -146,15 +156,15 @@ it('groups application lifecycle controls in an actions dropdown', function () {
|
||||
->toContain('Deploy');
|
||||
});
|
||||
|
||||
it('shows deploy directly when it is the only available lifecycle action', 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'));
|
||||
$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"');
|
||||
->toContain('id="application-desktop-actions"')
|
||||
->toContain('Deploy')
|
||||
->toContain('Force deploy without cache');
|
||||
});
|
||||
|
||||
it('moves application backups from the top tabs into the settings sidebar', function () {
|
||||
@@ -229,7 +239,24 @@ it('uses neutral icons for non-destructive resource actions', function () {
|
||||
->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"');
|
||||
->toContain('name="stop-circle"');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a circular stop icon in application and service action menus', function () {
|
||||
$icons = file_get_contents(resource_path('views/components/reicon.blade.php'));
|
||||
|
||||
expect($icons)
|
||||
->toContain("'stop-circle' =>")
|
||||
->toContain('<circle cx="12" cy="12"')
|
||||
->toContain('<rect x="8.25" y="8.25"');
|
||||
|
||||
foreach (['application', 'service'] as $resource) {
|
||||
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
|
||||
|
||||
expect($heading)
|
||||
->toContain('name="stop-circle" class="size-3.5 text-error"')
|
||||
->not->toContain('name="stop" class="size-3.5 text-error"');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -52,3 +52,15 @@ test('searchable listbox keeps the helper outside the label association', functi
|
||||
->toContain('aria-label="More information"')
|
||||
->not->toMatch('/<label[^>]*for="tz-trigger"[^>]*>[\s\S]*aria-label="More information"[\s\S]*<\/label>/');
|
||||
});
|
||||
|
||||
test('searchable listbox serializes change handlers', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/searchable-listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain('saving: false')
|
||||
->toContain('async choose(option)')
|
||||
->toContain('if (this.saving || option.disabled)')
|
||||
->toContain('await this.$wire.')
|
||||
->toContain("'pointer-events-none opacity-70': saving")
|
||||
->toContain('@elseif ($live && ! $onChange) @entangle($id).live');
|
||||
});
|
||||
@@ -41,3 +41,20 @@ it('uses the branded input focus state for the server filter', function () {
|
||||
->toContain('<x-reicon name="check-circle"')
|
||||
->not->toContain('<x-reicon name="check"');
|
||||
});
|
||||
|
||||
it('groups all desktop proxy controls in the server actions dropdown', function () {
|
||||
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
$desktopActions = str($navbar)->after('id="server-desktop-actions"')->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')
|
||||
->not->toContain('<x-modal-confirmation');
|
||||
});
|
||||
@@ -107,13 +107,18 @@ it('groups configured domains and shows redirect settings in the table', functio
|
||||
->toContain("id=\"service-domain-direction-{$this->apiApp->id}-0-trigger\"")
|
||||
->toContain("id=\"service-domain-indexing-{$this->apiApp->id}-0-trigger\"")
|
||||
->toContain('src="https://api.example.com/favicon.ico"')
|
||||
->toContain('class="relative size-4 shrink-0"')
|
||||
->toContain('domain-favicon-fallback')
|
||||
->toContain('class="invisible absolute inset-0 size-4 rounded-sm"')
|
||||
->toContain('$el.previousElementSibling.classList.add(\'hidden\')')
|
||||
->toContain('x-on:error="$el.remove()"')
|
||||
->toContain('class="min-w-0 flex-1 text-[13px]')
|
||||
->toContain('class="listbox-trigger"')
|
||||
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
|
||||
->toContain('dark:bg-white/[0.04]')
|
||||
->toContain('<span>Domain</span>')
|
||||
->toContain('<span>DNS</span>')
|
||||
->toContain('<span>Last checked</span>')
|
||||
->toContain('<span>DNS Check</span>')
|
||||
->not->toContain('<span>Last checked</span>')
|
||||
->not->toContain("service-domain-group-{$this->webApp->id}")
|
||||
->and(substr_count($html, '2 domains'))->toBe(1)
|
||||
->and(strpos($html, '>API</span>'))->toBeLessThan(strpos($html, '<span>Domain</span>'))
|
||||
@@ -556,12 +561,16 @@ it('updates search engine indexing from the service domains view', function () {
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateServiceRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success');
|
||||
->assertDispatched('success')
|
||||
->assertSet('service', fn (Service $service): bool => $service->applications
|
||||
->firstWhere('id', $this->apiApp->id)
|
||||
?->isDomainNoindexed('https://api.example.com') === true);
|
||||
|
||||
expect($this->apiApp->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://api.example.com']);
|
||||
|
||||
Reference in new issue
Block a user