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 a7b26bb66..b27ed68f2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -520,7 +520,10 @@ ### Unsaved changes 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 45e284c5d..a9e1c0be2 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 @@ private function syncData(bool $toModel = false): void $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 @@ private function syncData(bool $toModel = false): void $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 80eb9bf0a..906cc147a 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -150,7 +150,15 @@ public function mount(): void 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 @@ public function loadDomainState(): void $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 @@ public function loadDomainState(): void } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -290,7 +303,9 @@ public function loadDomainState(): void $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 @@ public function updatedShowDomainConflictModal(bool $value): void 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 fa0272bd6..832a0d55c 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 4ac2f40b2..b4f76d12d 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -129,9 +129,17 @@ public function mount(): void 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 @@ public function loadDomainState(): void ]) ->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 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -940,6 +954,11 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool 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 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool 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 @@ public function confirmRemovePort(): void 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 @@ protected function saveDomainListForApp( 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 @@ protected function saveDomainListForApp( } } - 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 65bce6327..e3e20cf75 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4396,3 +4396,63 @@ .command-palette-arch-badge { .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 8d029c2fb..cbc137e07 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 429689ce7..c6113808c 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 b80085b9d..93edafde5 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 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
@endif @can('update', $application) + + + Check all DNS +
@include('livewire.project.shared.cloudflare-autoconfigure')
@@ -118,7 +100,7 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
@@ -168,20 +150,46 @@ class="button button-highlighted">
+ @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 @@ class="border-b border-neutral-200 last:border-b-0 dark:border-white/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 @@ class="data-table w-full"> 'row' => $row, 'application' => $application, 'labelsAreWritable' => $labelsAreWritable, - 'isCompose' => false, - 'showDirectionControl' => false, - 'domainGridClass' => 'domains-table-grid-service', + 'isCompose' => true, ]) @endforeach
@@ -273,13 +253,6 @@ class="px-4 py-8">
@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 @@ class="px-4 py-8"> ]) @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. --}}