From 282227b238c1958a01721cb935da09761099db0a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:05:21 +0200 Subject: [PATCH 1/2] fix(domains): preserve ports in split URL inputs (#11328) --- app/Livewire/Project/Application/Domains.php | 34 ++++++++++ app/Livewire/Project/Service/Domains.php | 35 ++++++++++ app/Livewire/Storage/Create.php | 13 ++++ app/Livewire/Storage/Form.php | 18 +++++ .../components/forms/domain-input.blade.php | 67 ++++--------------- .../views/components/forms/listbox.blade.php | 5 +- .../project/application/domains.blade.php | 31 ++------- .../application/partials/domain-row.blade.php | 7 +- .../project/service/domains.blade.php | 30 ++------- .../service/partials/domain-table.blade.php | 8 +-- .../views/livewire/storage/create.blade.php | 2 +- .../views/livewire/storage/form.blade.php | 2 +- tests/Feature/ApplicationDomainsTest.php | 24 +++++-- .../Feature/ListboxTriggerTruncationTest.php | 9 +++ tests/Feature/ServiceDomainsTest.php | 4 +- tests/Feature/SplitUrlInputTest.php | 50 +++++--------- .../S3StorageEndpointNormalizationTest.php | 4 +- 17 files changed, 182 insertions(+), 161 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 9ddd5e740..477abc08e 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -6,6 +6,7 @@ use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; use App\Models\Server; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -35,12 +36,20 @@ class Domains extends Component public string $newDomain = ''; + public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $newDomainPartsChanged = false; + public ?string $newDomainService = null; public ?int $editingIndex = null; public string $editingDomain = ''; + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $editingDomainPartsChanged = false; + public ?string $editingService = null; /** @var array */ @@ -662,6 +671,12 @@ class Domains extends Component $this->resetAddDomainDnsGate(); } + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + public function updatedNewDomainService(): void { $this->resetAddDomainDnsGate(); @@ -677,6 +692,8 @@ class Domains extends Component public function resetAddDomainForm(): void { $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->resetAddDomainDnsGate(); $this->resetErrorBag('newDomain'); } @@ -743,6 +760,9 @@ class Domains extends Component return; } + if ($this->newDomainPartsChanged) { + $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); + } $this->validateOnly('newDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain); @@ -893,6 +913,12 @@ class Domains extends Component $this->resetEditDomainDnsGate(); } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->resetEditDomainDnsGate(); + } + public function resetEditDomainDnsGate(): void { $this->editDomainDnsFailed = false; @@ -908,10 +934,13 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; + $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; + $this->dispatch('open-edit-domain'); } public function addSuggestedDomain(int $index): void @@ -990,6 +1019,8 @@ class Domains extends Component $this->showEditDomainModal = false; $this->editingIndex = null; $this->editingDomain = ''; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->editingDomainPartsChanged = false; $this->editingService = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); @@ -1021,6 +1052,9 @@ class Domains extends Component return; } + if ($this->editingDomainPartsChanged) { + $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + } $this->validateOnly('editingDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index a5479ca06..ad31560b5 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -43,10 +43,18 @@ class Domains extends Component public string $newDomain = ''; + public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $newDomainPartsChanged = false; + public ?int $editingIndex = null; public string $editingDomain = ''; + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $editingDomainPartsChanged = false; + public ?int $editingServiceApplicationId = null; public bool $showEditDomainModal = false; @@ -515,6 +523,12 @@ class Domains extends Component $this->forceSaveDns = false; } + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + public function updatedEditingDomain(): void { $this->editDomainDnsFailed = false; @@ -522,6 +536,12 @@ class Domains extends Component $this->forceSaveEditDns = false; } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->updatedEditingDomain(); + } + public function confirmAddDomainDespiteDns(): void { $this->forceSaveDns = true; @@ -842,6 +862,9 @@ class Domains extends Component { try { $this->authorize('update', $this->service); + if ($this->newDomainPartsChanged) { + $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); + } $this->validateOnly('newDomain'); $app = $this->findServiceApp($this->newServiceApplicationId); @@ -893,6 +916,8 @@ class Domains extends Component } $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->addDomainDnsFailed = false; $this->addDomainDnsMessage = ''; $this->forceSaveDns = false; @@ -916,12 +941,15 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; + $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; + $this->dispatch('open-edit-domain'); } public function cancelEdit(): void @@ -929,6 +957,8 @@ class Domains extends Component $this->showEditDomainModal = false; $this->editingIndex = null; $this->editingDomain = ''; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = null; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; @@ -945,6 +975,9 @@ class Domains extends Component return; } + if ($this->editingDomainPartsChanged) { + $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + } $this->validateOnly('editingDomain'); $app = $this->findServiceApp($this->editingServiceApplicationId); @@ -1130,6 +1163,8 @@ class Domains extends Component } $this->newDomain = $domain; + $this->newDomainParts = DomainUrlParts::split($domain); + $this->newDomainPartsChanged = true; $this->updatedNewDomain(); } catch (\Throwable $e) { handleError($e, $this); diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index d741e6918..9e22e5491 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -5,6 +5,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Uri; @@ -28,6 +29,10 @@ class Create extends Component public string $endpoint = ''; + public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $endpointPartsChanged = false; + public S3Storage $storage; protected function rules(): array @@ -76,6 +81,9 @@ class Create extends Component try { $this->authorize('create', S3Storage::class); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } $this->endpoint = $this->normalizeEndpoint($this->endpoint); $this->validate(); $this->storage = new S3Storage; @@ -101,6 +109,11 @@ class Create extends Component } } + public function updatedEndpointParts(): void + { + $this->endpointPartsChanged = true; + } + private function connectionErrorDescription(\Throwable $exception): string { $settingsUrl = route('settings.advanced').'#endpoint-section'; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 94a0657ef..30084dcd3 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -5,6 +5,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -24,6 +25,10 @@ class Form extends Component public string $endpoint; + public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $endpointPartsChanged = false; + public string $bucket; public string $region; @@ -101,6 +106,8 @@ class Form extends Component $this->name = $this->storage->name; $this->description = $this->storage->description; $this->endpoint = $this->storage->endpoint; + $this->endpointParts = DomainUrlParts::split($this->endpoint); + $this->endpointPartsChanged = false; $this->bucket = $this->storage->bucket; $this->region = $this->storage->region; $this->key = $this->storage->key; @@ -126,6 +133,9 @@ class Form extends Component try { $this->authorize('validateConnection', $this->storage); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } $testedStorage = new S3Storage; $testedStorage->uuid = $this->storage->uuid; $testedStorage->team_id = $this->storage->team_id; @@ -166,6 +176,9 @@ class Form extends Component { try { $this->authorize('update', $this->storage); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } DB::transaction(function () { $this->validate(); @@ -195,4 +208,9 @@ class Form extends Component return handleError($e, $this); } } + + public function updatedEndpointParts(): void + { + $this->endpointPartsChanged = true; + } } diff --git a/resources/views/components/forms/domain-input.blade.php b/resources/views/components/forms/domain-input.blade.php index d6e6bce85..5a4b5f950 100644 --- a/resources/views/components/forms/domain-input.blade.php +++ b/resources/views/components/forms/domain-input.blade.php @@ -1,68 +1,27 @@ @props([ 'id', - 'wire' => true, - 'value' => '', 'errorId' => null, 'hostLabel' => 'Domain', 'hostPlaceholder' => 'app.example.com', ]) -
whereStartsWith('x-model') }}> +
- +
-
- - @error($errorId ?? $id) + + @error($errorId ?? "{$id}.host") @php preg_match('/(https?:\/\/\S+)$/', $message, $validationLinkMatches); $validationLink = $validationLinkMatches[1] ?? null; @@ -82,16 +41,16 @@
- +
- +

Optional path, query, or fragment appended after the domain and port.

diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index bfcb4437e..4677911d9 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -90,6 +90,9 @@ const gap = 4; const edge = 12; const triggerRect = trigger.getBoundingClientRect(); + panel.style.width = 'max-content'; + panel.style.minWidth = `${triggerRect.width}px`; + panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`; const panelWidth = Math.min( Math.max(triggerRect.width, panel.offsetWidth), window.innerWidth - (edge * 2), @@ -107,8 +110,6 @@ panel.style.top = `${top}px`; panel.style.left = `${left}px`; panel.style.width = `${panelWidth}px`; - panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`; - panel.style.minWidth = `${triggerRect.width}px`; this.positioned = true; }, }" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }" diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index fb5bf9b1d..04dd249c7 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -15,30 +15,14 @@ domainSearch: '', modalOpen: @js($showEditDomainModal || $editDomainDnsFailed), editingServiceLabel: @js($editingService ?? ''), - localEditingIndex: @js($editingIndex), - localEditingDomain: @js($editingDomain), - localEditingService: @js($editingService), - openEditDomain(index, url, service) { - this.localEditingIndex = index; - this.localEditingDomain = url; - this.localEditingService = service; - this.editingServiceLabel = service || ''; + openEditDomain() { + this.editingServiceLabel = $wire.editingService || ''; this.modalOpen = true; this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.()); }, closeEditDomain() { this.modalOpen = false; this.editingServiceLabel = ''; - this.localEditingIndex = null; - this.localEditingDomain = ''; - this.localEditingService = null; - }, - prepareEditSubmit() { - // Sync Alpine → Livewire only when the user actually saves (one request). - $wire.editingIndex = this.localEditingIndex; - $wire.editingDomain = this.localEditingDomain; - $wire.editingService = this.localEditingService; - $wire.showEditDomainModal = true; }, matchesDomainSearch(value) { return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); @@ -47,7 +31,7 @@ return values.some((value) => this.matchesDomainSearch(value)); }, }" - @open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)" + @open-edit-domain.window="openEditDomain()" @edit-domain-saved.window="closeEditDomain()"> @can('update', $application) @@ -128,7 +112,7 @@ :disabled="! auth()->user()->can('update', $application)" /> @endif - + @if ($addDomainDnsFailed) @@ -320,7 +304,7 @@
-
+
@@ -328,8 +312,7 @@
- + @if ($editDomainDnsFailed) @@ -345,7 +328,7 @@
@if ($editDomainDnsFailed) + wire:click="confirmUpdateDomainDespiteDns"> Continue @else diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index d5baaee56..10aa21200 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -178,12 +178,7 @@ @endif @else - diff --git a/resources/views/livewire/storage/create.blade.php b/resources/views/livewire/storage/create.blade.php index 1154a7e90..c0d6a6e27 100644 --- a/resources/views/livewire/storage/create.blade.php +++ b/resources/views/livewire/storage/create.blade.php @@ -7,7 +7,7 @@
-
diff --git a/resources/views/livewire/storage/form.blade.php b/resources/views/livewire/storage/form.blade.php index 2a76e7cc6..d13d632fb 100644 --- a/resources/views/livewire/storage/form.blade.php +++ b/resources/views/livewire/storage/form.blade.php @@ -17,7 +17,7 @@
@can('update', $storage) - @else diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 288952a7d..fbe72230d 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -236,6 +236,22 @@ it('adds a domain to the application', function () { ->toBe(['https://app.example.com', 'https://www.app.example.com']); }); +it('composes the complete port on the server without duplicating an existing www domain', function () { + $this->application->update(['fqdn' => 'https://www.example.com:3000']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('newDomainParts.host', 'example.com') + ->set('newDomainParts.port', '3000') + ->call('addDomain') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([ + 'https://www.example.com:3000', + 'https://example.com:3000', + ]); +}); + it('adds multiple domains without replacing existing ones', function () { $this->application->update([ 'fqdn' => 'https://app.example.com', @@ -1222,16 +1238,16 @@ it('uses segmented fields when adding and editing application domains', function $component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php')); expect($view) - ->toContain('toContain('toContain('toContain('not->toContain('placeholder="https://app.example.com"') ->and($component) ->toContain('Protocol') ->toContain('Domain') ->toContain('Port') ->toContain('Path') - ->toContain("scheme: 'https'") - ->toContain('toContain('wire:model="{{ $id }}.host"') + ->toContain('not->toContain('