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:
Andras Bacsai committed 2026-08-11 00:06:55 +02:00
1 parent 61a2b183d3
commit 32bf1860d4
30 files changed
+693 -292

No files matched your search

+4 -3
View File
@@ -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 () {
+55 -13
View File
@@ -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')
+16 -1
View File
@@ -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 () {
+10
View File
@@ -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');
});
+28 -1
View File
@@ -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."');
});
+36 -13
View File
@@ -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')
+30
View File
@@ -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');
});