mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 08:18:08 +00:00
feat(domains): add structured URL editing and responsive domain controls
Add reusable domain URL parsing and input components, expose per-domain redirect and indexing controls, and improve application and service domain layouts across responsive breakpoints.
This commit is contained in:
1 parent
61a2b183d3
commit
32bf1860d4
30 files changed
+693
-292
No files matched your search
@@ -126,9 +126,10 @@ test('Server-Timing HUD docks into navbar slots and floats only as fallback', fu
|
||||
->toContain('server-timing-hud-slot-mobile')
|
||||
->toContain("matchMedia('(min-width: 1024px)')")
|
||||
->toContain('floats bottom-left only if no navbar slot is available')
|
||||
// Mobile pill is compact (app ms only); full "ST · db · q" breakdown stays desktop/float.
|
||||
->toContain("parentElement.id === 'server-timing-hud-slot-mobile'")
|
||||
->toContain('compactSummary');
|
||||
// Both navbar pills stay compact (app ms only); the full breakdown lives in the panel/float fallback.
|
||||
->toContain("root.getAttribute('data-sth-mode') === 'docked'")
|
||||
->toContain('compactSummary')
|
||||
->not->toContain("compactSummary = root.getAttribute('data-sth-mode') === 'docked'\n && root.parentElement");
|
||||
});
|
||||
|
||||
test('Server-Timing HUD follows the application color mode', function () {
|
||||
|
||||
@@ -98,15 +98,20 @@ it('uses safe domain validation rules on the domains form', function () {
|
||||
|
||||
it('lists existing domains as individual rows', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com,https://www.example.com',
|
||||
'fqdn' => 'https://example.com,https://www.example.com,https://another.example.com,https://www.another.example.com',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSuccessful()
|
||||
->assertSet('domainRows.0.url', 'https://app.example.com')
|
||||
->assertSet('domainRows.0.url', 'https://example.com')
|
||||
->assertSet('domainRows.1.url', 'https://www.example.com')
|
||||
->assertSee('https://app.example.com')
|
||||
->assertSee('https://www.example.com');
|
||||
->assertSee('https://example.com')
|
||||
->assertSee('https://www.example.com')
|
||||
->assertSee('https://example.com/favicon.ico', false)
|
||||
->assertSee('x-on:error="$el.remove()"', false)
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2);
|
||||
});
|
||||
|
||||
it('shows dns entries control next to Add', function () {
|
||||
@@ -1221,13 +1226,49 @@ it('uses the compact service domains layout for compose applications', function
|
||||
->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('id="edit-domain-direction"')
|
||||
->toContain('<span>Direction</span>')
|
||||
->toContain('<span>Search engine indexing</span>')
|
||||
->not->toContain('id="edit-domain-direction"')
|
||||
->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"')
|
||||
->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
it('updates a compose service redirect from the edit domain modal', function () {
|
||||
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'));
|
||||
|
||||
expect($styles)
|
||||
->toContain('@media (max-width: 768px)')
|
||||
->toContain('.domains-mobile-label')
|
||||
->toContain('.domains-table-grid .listbox-trigger')
|
||||
->and($row)
|
||||
->toContain('domains-mobile-label')
|
||||
->toContain('Search engine indexing')
|
||||
->toContain('Direction');
|
||||
});
|
||||
|
||||
it('uses segmented fields when adding and editing application domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-forms.domain-input id="newDomain"')
|
||||
->toContain('<x-forms.domain-input id="editingDomainLocal"')
|
||||
->not->toContain('placeholder="https://app.example.com"')
|
||||
->and($component)
|
||||
->toContain('Protocol')
|
||||
->toContain('Domain')
|
||||
->toContain('Port')
|
||||
->toContain('Path')
|
||||
->toContain("scheme: 'https'")
|
||||
->toContain('<x-forms.listbox id="{{ $id }}-protocol"')
|
||||
->not->toContain('<select id="{{ $id }}-protocol"')
|
||||
->toContain('min="1"')
|
||||
->toContain('max="65535"');
|
||||
});
|
||||
|
||||
it('updates a compose service redirect from the domains table', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
@@ -1240,10 +1281,7 @@ it('updates a compose service redirect from the edit domain modal', function ()
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('isCompose', true)
|
||||
->set('composeServices', ['web'])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDirection', 'both')
|
||||
->set('editingDirection', 'www')
|
||||
->call('updateDomain')
|
||||
->call('updateServiceRedirect', 'web', 'www')
|
||||
->assertDispatched('success');
|
||||
|
||||
$domains = json_decode($this->application->fresh()->docker_compose_domains, true);
|
||||
@@ -1365,8 +1403,12 @@ it('updates search engine indexing from the domains view', function () {
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Noindex')
|
||||
->assertSee('Indexable')
|
||||
->assertSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateRedirect', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex')
|
||||
->assertDispatched('configurationChanged')
|
||||
|
||||
@@ -18,7 +18,22 @@ test('docker compose heading separates its title and action', function () {
|
||||
|
||||
expect($view)
|
||||
->toContain('<div x-data="{ showRaw: true }" class="mt-5">')
|
||||
->toContain('<div class="flex items-center justify-between gap-4">');
|
||||
->toContain('<div class="mb-2 flex items-center justify-between gap-4">');
|
||||
});
|
||||
|
||||
test('unsaved changes ignore compose initialization state', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$unsavedBar = str($view)
|
||||
->after('<x-unsaved-bar action="submit"')
|
||||
->before('/>')
|
||||
->toString();
|
||||
|
||||
expect($unsavedBar)
|
||||
->toContain('targets="')
|
||||
->toContain('name,description')
|
||||
->not->toContain('initLoadingCompose')
|
||||
->not->toContain('dockerComposeRaw')
|
||||
->not->toContain('dockerCompose,');
|
||||
});
|
||||
|
||||
test('onboarding uses the reusable advanced settings component', function () {
|
||||
|
||||
@@ -35,3 +35,13 @@ it('keeps a tooltip hidden until its measured position is applied', function ()
|
||||
->toContain('this.$nextTick(() => this.positioned = true);')
|
||||
->toContain("positioned ? 'visible' : 'invisible'");
|
||||
});
|
||||
|
||||
it('anchors tooltips to the trigger and lets them grow toward the right', function () {
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($tooltip)
|
||||
->toContain('this.x = rect.left;')
|
||||
->toContain('Math.min(window.innerWidth - width - 8, this.x)')
|
||||
->not->toContain('rect.left + rect.width / 2')
|
||||
->not->toContain('-translate-x-1/2');
|
||||
});
|
||||
@@ -54,10 +54,14 @@ it('opens the command palette without changing page scrollbar visibility', funct
|
||||
it('animates the command palette with tw animate utilities', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
// Exit animations need fill-mode-forwards: without it the element snaps
|
||||
// back to full opacity when the keyframe animation ends, one frame before
|
||||
// Alpine applies display:none, which flashes the palette on close.
|
||||
expect($view)
|
||||
->toContain('<div x-show="modalOpen" @click="closeModal()"')
|
||||
->toContain('x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100 fill-mode-forwards"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 duration-100 fill-mode-forwards"')
|
||||
->not->toContain('<div x-show="modalOpen" x-cloak\n class="fixed inset-0');
|
||||
});
|
||||
|
||||
@@ -68,3 +72,26 @@ it('closes the client-side command palette without a Livewire request', function
|
||||
->not->toContain('$wire.closeSearchModal()')
|
||||
->not->toContain('closeTimer');
|
||||
});
|
||||
|
||||
it('keeps palette content intact during the close animation to prevent flicker', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
// closeModal() must only hide the palette immediately; content resets
|
||||
// (searchQuery, allSearchableItems) are deferred past the 100ms leave
|
||||
// animation so the panel does not collapse while fading out.
|
||||
expect($view)
|
||||
->toContain('clearTimeout(this.closeResetTimer);')
|
||||
->toContain("this.closeResetTimer = setTimeout(() => {\n this.isLoadingInitialData = false;")
|
||||
->toContain("this.searchQuery = '';\n this.allSearchableItems = [];");
|
||||
});
|
||||
|
||||
it('delays the header spinner so fast cached loads do not flash the icon', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('showLoadingSpinner')
|
||||
->toContain('this.spinnerTimer = setTimeout(')
|
||||
->toContain('x-show="!showLoadingSpinner"')
|
||||
->toContain('x-show="showLoadingSpinner"')
|
||||
->not->toContain(':class="isLoadingInitialData && \'is-loading\'"');
|
||||
});
|
||||
@@ -76,6 +76,7 @@ it('renders the changed configuration labels without a second backend request',
|
||||
expect($view)
|
||||
->toContain(':compact-after="5000"')
|
||||
->toContain(':compact-storage-key="$compactStorageKey"')
|
||||
->toContain('wire:key="configuration-warning-{{ $currentConfigurationHash }}"')
|
||||
->toContain('x-on:click="configurationDiffModalOpen = true"')
|
||||
->not->toContain('$wire.refreshConfigurationChanges()');
|
||||
});
|
||||
@@ -93,9 +94,11 @@ it('supports timed compact popup notifications', function () {
|
||||
->toContain('compact = true')
|
||||
->toContain('@click="restore()"')
|
||||
->toContain('@click.stop="minimizeToIcon()"')
|
||||
->toContain('x-show="iconOnly"')
|
||||
->toContain('x-show="!iconOnly"')
|
||||
->not->toContain(':class="iconOnly')
|
||||
->toContain('x-show="!compact"')
|
||||
->toContain("'w-[calc(100%-2rem)] sm:w-auto sm:max-w-[calc(100%-2rem)]'");
|
||||
->toContain("'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'");
|
||||
});
|
||||
|
||||
it('warns when a service has missing required environment variables', function () {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
test('remaining user-facing dropdown fields use the shared animated listbox', function () {
|
||||
$views = [
|
||||
'livewire/source/gitlab/change.blade.php' => ['privateKeyId', 'webhook_endpoint'],
|
||||
'livewire/project/application/domains.blade.php' => ['newDomainService'],
|
||||
'livewire/project/service/domains.blade.php' => ['newServiceApplicationId'],
|
||||
'livewire/server/new/by-ip.blade.php' => ['is_build_server'],
|
||||
];
|
||||
|
||||
foreach ($views as $path => $ids) {
|
||||
$view = file_get_contents(resource_path('views/'.$path));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
expect($view)
|
||||
->toContain('<x-forms.listbox')
|
||||
->toContain('id="'.$id.'"')
|
||||
->not->toMatch('/<x-forms\.select[^>]*id="'.preg_quote($id, '/').'"/s');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,13 @@ it('renders the resource terminal shell while containers are discovered', functi
|
||||
->not->toContain('<x-loading text="Loading containers" />');
|
||||
});
|
||||
|
||||
it('marks stopped services as loaded before the terminal page first renders', function () {
|
||||
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toMatch('/elseif \(data_get\(\$this->parameters, \'service_uuid\'\)\).*?if \(! \$this->resource->isRunning\(\)\) \{\s*\$this->containersLoaded = true;\s*\}/s');
|
||||
});
|
||||
|
||||
it('provides opt-in diagnostics for connected terminal theme changes', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ test('server creation keeps private key actions together and advanced options co
|
||||
expect($view)
|
||||
->toContain('class="flex items-end gap-3"')
|
||||
->toContain('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
|
||||
->toContain('<x-forms.select id="is_build_server"')
|
||||
->toContain('<x-forms.listbox id="is_build_server"')
|
||||
->toContain('label="Use as a dedicated build server"')
|
||||
->toContain('<option value="0">No</option>')
|
||||
->toContain('<option value="1">Yes</option>')
|
||||
->toContain("['value' => false, 'label' => 'No']")
|
||||
->toContain("['value' => true, 'label' => 'Yes']")
|
||||
->not->toContain('<x-forms.checkbox id="is_build_server"')
|
||||
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups configured domains and shows redirect settings in the edit modal', function () {
|
||||
it('groups configured domains and shows redirect settings in the table', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com,https://admin.example.com',
|
||||
]);
|
||||
@@ -100,13 +100,14 @@ it('groups configured domains and shows redirect settings in the edit modal', fu
|
||||
->assertSee('API')
|
||||
->assertSee('https://api.example.com')
|
||||
->assertSee('https://admin.example.com')
|
||||
->call('startEdit', 0)
|
||||
->html();
|
||||
|
||||
expect($html)
|
||||
->toContain("service-domain-group-{$this->apiApp->id}")
|
||||
->toContain("id=\"edit-service-domain-redirect-{$this->apiApp->id}-trigger\"")
|
||||
->toContain("serviceRedirects.{$this->apiApp->id}")
|
||||
->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('x-on:error="$el.remove()"')
|
||||
->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]')
|
||||
@@ -119,6 +120,27 @@ it('groups configured domains and shows redirect settings in the edit modal', fu
|
||||
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
|
||||
});
|
||||
|
||||
it('shows one redirect control for each www and non-www pair', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com,https://www.api.example.com,https://admin.example.com,https://www.admin.example.com',
|
||||
]);
|
||||
|
||||
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSuccessful()
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(2);
|
||||
});
|
||||
|
||||
it('uses segmented fields when adding and editing service domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-forms.domain-input id="newDomain"')
|
||||
->toContain('<x-forms.domain-input id="editingDomainLocal"')
|
||||
->not->toContain('placeholder="https://app.example.com"');
|
||||
});
|
||||
|
||||
it('shows dns entries control next to Add', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSuccessful()
|
||||
@@ -366,13 +388,11 @@ it('prunes the previous dns status when a service domain is renamed', function (
|
||||
->and($this->apiApp->domain_dns_statuses['https://renamed.example.com']['status'])->toBe('skipped');
|
||||
});
|
||||
|
||||
it('only shows the domain notification when redirect changes through edit domain', function () {
|
||||
it('updates redirect independently from editing a domain', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDirection', 'www')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('success', 'Domain updated.')
|
||||
->assertNotDispatched('success', 'Redirect updated.');
|
||||
->call('updateServiceRedirect', $this->apiApp->id, 'www')
|
||||
->assertDispatched('success', 'Redirect updated.')
|
||||
->assertNotDispatched('success', 'Domain updated.');
|
||||
});
|
||||
|
||||
it('does not restore stale dns status when a removed service domain is re-added', function () {
|
||||
@@ -532,9 +552,12 @@ it('updates search engine indexing from the service domains view', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSee('Noindex')
|
||||
->assertSee('Indexable')
|
||||
->assertSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
|
||||
->assertDontSee('@change="$wire.updateServiceRedirect', false)
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateServiceRedirect', 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')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Support\DomainUrlParts;
|
||||
|
||||
it('composes a domain URL from segmented fields', function () {
|
||||
expect(DomainUrlParts::compose('https', 'app.example.com', '3000', 'api/v3'))
|
||||
->toBe('https://app.example.com:3000/api/v3');
|
||||
});
|
||||
|
||||
it('splits a domain URL while preserving its path query and fragment', function () {
|
||||
expect(DomainUrlParts::split('http://app.example.com:8080/api?v=1#docs'))->toBe([
|
||||
'scheme' => 'http',
|
||||
'host' => 'app.example.com',
|
||||
'port' => '8080',
|
||||
'path' => '/api?v=1#docs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults empty values for an invalid URL', function () {
|
||||
expect(DomainUrlParts::split(''))->toBe([
|
||||
'scheme' => 'https',
|
||||
'host' => '',
|
||||
'port' => '',
|
||||
'path' => '',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves an explicitly configured default port', function () {
|
||||
expect(DomainUrlParts::split('https://app.example.com:443')['port'])->toBe('443');
|
||||
});
|
||||
Reference in new issue
Block a user