From d75881fa96b49f28f00979a18457d9960f05b60a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:29:58 +0200 Subject: [PATCH] feat(domains): keep domain drafts and move preview settings Preserve in-progress domain edits and redirects across Livewire refreshes, copy www port overrides for service redirect pairs, and relocate preview deployment toggles from Advanced to Previews. Unsaved bars can stay dirty via Alpine while a modal is closed, and service domain tables stack at narrow widths. --- DESIGN.md | 5 +- app/Livewire/Project/Application/Advanced.php | 10 - app/Livewire/Project/Application/Domains.php | 25 +- app/Livewire/Project/Application/Previews.php | 22 ++ app/Livewire/Project/Service/Domains.php | 42 +++- resources/css/app.css | 60 +++++ .../views/components/unsaved-bar.blade.php | 5 +- .../project/application/advanced.blade.php | 14 +- .../project/application/domains.blade.php | 220 +++++++++-------- .../application/partials/domain-row.blade.php | 143 ++++------- .../project/application/previews.blade.php | 23 ++ .../project/service/domains.blade.php | 151 ++++++++---- .../service/partials/domain-table.blade.php | 131 ++++------ tests/Feature/ApplicationDomainsTest.php | 202 +++++++++++++--- .../ApplicationPreviewSettingsTest.php | 139 +++++++++++ tests/Feature/ServiceDomainsTest.php | 224 ++++++++++++++++-- .../Browser/ApplicationConfigurationTest.php | 85 +++++++ tests/v4/Browser/ServiceConfigurationTest.php | 129 ++++++++++ 18 files changed, 1204 insertions(+), 426 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewSettingsTest.php diff --git a/DESIGN.md b/DESIGN.md index a7b26bb666..b27ed68f2d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -520,7 +520,10 @@ Do not restore the old full-width footer. Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing -instant-save pattern. +instant-save pattern. When those requests share a component with a modal draft, +pass the unsaved bar a `dirty` Alpine expression comparing that draft with its +initial values, so unrelated saves do not hide pending changes. Mount modal save +bars only while the modal is open to avoid inactive keyboard shortcuts. --- diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 45e284c5dc..a9e1c0be28 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -27,12 +27,6 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitShallowCloneEnabled = false; - #[Validate(['boolean'])] - public bool $isPreviewDeploymentsEnabled = false; - - #[Validate(['boolean'])] - public bool $isPrDeploymentsPublicEnabled = false; - #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -107,8 +101,6 @@ class Advanced extends Component $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; - $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; - $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -136,8 +128,6 @@ class Advanced extends Component $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; - $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; - $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 80eb9bf0a8..906cc147a0 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -150,7 +150,15 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && ($row['service'] ?? null) === ($editingRow['service'] ?? null)); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -227,7 +235,9 @@ class Domains extends Component $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; - $this->redirect = $this->application->redirect ?? 'both'; + if ($this->pendingAction !== 'redirect' || $this->isCompose) { + $this->redirect = $this->application->redirect ?? 'both'; + } $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); @@ -254,6 +264,9 @@ class Domains extends Component } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -290,7 +303,9 @@ class Domains extends Component $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( - is_string($storedRedirect) ? $storedRedirect : null + $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService + ? $pendingRedirect + : (is_string($storedRedirect) ? $storedRedirect : null) ); } } @@ -973,7 +988,13 @@ class Domains extends Component return; } + $this->authorize('update', $this->application); + $wasRedirect = $this->pendingAction === 'redirect'; $this->pendingAction = null; + $this->pendingRedirectService = null; + if ($wasRedirect) { + $this->refreshDomains(); + } } public function addDomain(): void diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index fa0272bd64..832a0d55cf 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -19,6 +19,10 @@ class Previews extends Component public Application $application; + public bool $isPreviewDeploymentsEnabled = false; + + public bool $isPrDeploymentsPublicEnabled = false; + public string $deployment_uuid; public array $parameters; @@ -41,11 +45,29 @@ class Previews extends Component public function mount() { + $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->pull_requests = collect(); $this->parameters = get_route_parameters(); $this->syncDockerTags(); } + public function savePreviewSettings(): void + { + $this->authorize('update', $this->application); + $this->validate([ + 'isPreviewDeploymentsEnabled' => 'boolean', + 'isPrDeploymentsPublicEnabled' => 'boolean', + ]); + + $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; + $this->application->settings->save(); + + $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); + } + private function syncDockerTags(): void { $this->previewDockerTags = []; diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4ac2f40b2b..b4f76d12d9 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -129,9 +129,17 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->service->refresh(); $this->service->load(['applications', 'server']); $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && (int) $row['service_application_id'] === (int) $editingRow['service_application_id']); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -239,9 +247,14 @@ class Domains extends Component ]) ->all(); + $pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null; $this->serviceRedirects = []; foreach ($this->service->applications as $app) { - $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + $this->serviceRedirects[$app->id] = $this->normalizeRedirect( + $this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId + ? $pendingRedirect + : $app->redirect + ); } if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { @@ -924,6 +937,7 @@ class Domains extends Component } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -940,6 +954,11 @@ class Domains extends Component continue; } + $port = $this->effectiveDomainInternalPort($url, $app); + if ($port['has_port_override']) { + $portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port']; + } + $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } @@ -948,12 +967,13 @@ class Domains extends Component return true; } + $app->domain_port_overrides = $portOverrides ?: null; $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectServiceApplicationId = $app->id; - // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged)) { + // Counterparts inherit an existing port, so only domain conflicts need confirmation. + if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) { return false; } @@ -980,11 +1000,24 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect($this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } public function cancelRemovePort(): void { + $this->authorize('update', $this->service); + + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $app = $this->findServiceApp($this->pendingRedirectServiceApplicationId); + $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect); + } + $this->pendingRedirectServiceApplicationId = null; $this->showPortWarningModal = false; $this->forceSaveDomains = false; $this->forceRemovePort = false; @@ -1421,6 +1454,7 @@ class Domains extends Component ServiceApplication $app, Collection $domains, bool $checkConflicts = true, + bool $checkPorts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1447,7 +1481,7 @@ class Domains extends Component } } - if (! $this->forceRemovePort) { + if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { $previousFqdn = $app->getOriginal('fqdn'); diff --git a/resources/css/app.css b/resources/css/app.css index 65bce6327d..e3e20cf75e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4396,3 +4396,63 @@ a.command-palette-item:focus-visible { .dark .command-palette-arch-badge { color: #fcd34d; } + +/* Service domains prioritize public addresses; configuration lives in settings. */ +#service-domains-section, +.domains-overview-container { + container: service-domains / inline-size; +} + +.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) 7.25rem 7.5rem 5.5rem 6.5rem 8rem 6.5rem; + column-gap: 0.75rem; +} + +.data-table-row.service-domains-overview-grid { + padding-block: 0.5rem; +} + +.service-domain-detail { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + font-size: 12px; +} + +.service-domains-overview-grid > span:not(:first-child):not(:last-child) { + text-align: center; +} + +.service-domain-detail-label { + display: none; +} + +.service-domains-https .listbox-trigger { + min-width: 7rem; +} + +@container service-domains (max-width: 980px) { + .data-table-header.service-domains-overview-grid { + display: none; + } + + .data-table-row.service-domains-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-column: 1 / -1; + } + + .service-domain-detail { + justify-content: space-between; + gap: 0.5rem; + } + + .service-domain-detail-label { + display: inline; + color: var(--coollabs-fg-dim); + } +} diff --git a/resources/views/components/unsaved-bar.blade.php b/resources/views/components/unsaved-bar.blade.php index 8d029c2fb3..cbc137e07b 100644 --- a/resources/views/components/unsaved-bar.blade.php +++ b/resources/views/components/unsaved-bar.blade.php @@ -5,6 +5,8 @@ // appears when those fields differ from the last server snapshot — not on // incidental component state (e.g. $wire.set from x-init, display-only props). 'targets' => null, + // Optional Alpine expression for drafts that survive unrelated server requests. + 'dirty' => null, ]) {{-- Floating "unsaved changes" pill (bottom center). Reveals itself via @@ -40,7 +42,8 @@ window.visualViewport?.removeEventListener('scroll', this.updateKeyboardInset); window.removeEventListener('resize', this.updateKeyboardInset); }, -}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty" +}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" + @if ($dirty) x-bind:class="{ 'is-dirty': {{ $dirty }} }" @else wire:dirty.class="is-dirty" @endif wire:loading.class="is-saving" @keydown.enter.window=" if ($el.classList.contains('is-dirty') && diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 429689ce7b..c6113808c5 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -55,7 +55,7 @@ @if ($application->git_based()) + helper="Automatic deployments from Git webhooks.">
true, 'label' => 'Deploy on push (webhooks)'], ['value' => false, 'label' => 'Manual deployments only'], ]" :disabled="! $canUpdate" /> - -
diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index b80085b9d5..93edafde59 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -6,26 +6,31 @@ $composeDomainGroups = collect($domainRows) ->groupBy(fn ($row) => $row['service'] ?? '__unknown') ->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false))); - $helperText = $isCompose - ? 'Manage domains for every service in this Docker Compose application.' - : 'Manage domains for this application.'; $hasHttpsDomains = collect($domainRows)->contains( fn ($row) => ! ($row['is_suggested'] ?? false) && str_starts_with(strtolower($row['url']), 'https://') ); @endphp - @endif - - @can('update', $application) - - - - Recheck DNS - - - @endcan + @if ($labelsAreWritable) + + Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. + + @endif - @if ($labelsAreWritable) - - Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. - - @endif + @if ($isCompose && count($composeServices) === 0) + + No non-database services found in the Docker Compose file. Domains can only be assigned to application + services. + + @endif - @if ($isCompose && count($composeServices) === 0) - - No non-database services found in the Docker Compose file. Domains can only be assigned to application - services. - - @endif - - @cannot('update', $application) - - You don't have permission to manage domains. Contact your team administrator for access. - - @endcannot - -

- {{ $helperText }} -

- - @if ($hasHttpsDomains && ! $labelsAreWritable) -
- -
- @endif - -
+ @cannot('update', $application) + + You don't have permission to manage domains. Contact your team administrator for access. + + @endcannot {{-- Toolbar --}} -
+
+

Domains

{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} @if ($suggestedCount > 0) @@ -98,7 +76,7 @@

- @if ($isCompose && $composeDomainGroups->isNotEmpty()) + @if ($hasRows)
@@ -107,6 +85,10 @@
@endif @can('update', $application) + + + Check all DNS +
@include('livewire.project.shared.cloudflare-autoconfigure')
@@ -118,7 +100,7 @@
@@ -168,20 +150,46 @@
+ @if ($hasHttpsDomains && ! $labelsAreWritable) +
+ + +
+ +
+
+ @endif + {{-- Table / empty --}}
+ @if ($hasRows) +
+ Domain + Protocol redirect + Domain redirect + Internal port + Search indexing + DNS status + Actions +
+ @endif @if ($isCompose && count($composeServices) === 0 && ! $hasRows) @elseif ($isCompose && $composeDomainGroups->isEmpty()) @elseif (! $hasRows) @elseif ($isCompose) @php @@ -213,36 +221,10 @@ {{ $serviceName }} -
- - @if (auth()->user()?->can('update', $application) && ! $labelsAreWritable) - - @else - - {{ match ($serviceRedirects[$redirectWireKey] ?? 'both') { - 'www' => 'Redirect to www', - 'non-www' => 'Redirect to non-www', - default => 'Allow both', - } }} - - @endif -
-
-
- Domain - DNS Check - Search engine indexing - -
@foreach ($rows as $row) @php $index = collect($domainRows)->search( @@ -256,9 +238,7 @@ 'row' => $row, 'application' => $application, 'labelsAreWritable' => $labelsAreWritable, - 'isCompose' => false, - 'showDirectionControl' => false, - 'domainGridClass' => 'domains-table-grid-service', + 'isCompose' => true, ]) @endforeach
@@ -273,13 +253,6 @@
@else
-
- Domain - DNS Check - Search engine indexing - Direction - -
@foreach ($domainRows as $index => $row) @include('livewire.project.application.partials.domain-row', [ 'index' => $index, @@ -290,10 +263,15 @@ ]) @endforeach
+
+ +
@endif - {{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}} + {{-- One dialog for address edits and automatically saved domain settings. --}}