From 32bf1860d4ef38466b424f5f79c0533f971caacc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:06:55 +0200 Subject: [PATCH] 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. --- .ai/lessons.md | 7 ++ app/Livewire/Project/Application/Domains.php | 32 +++---- app/Livewire/Project/Service/Domains.php | 30 ++---- .../Shared/ExecuteContainerCommand.php | 3 + app/Support/DomainUrlParts.php | 56 +++++++++++ resources/css/app.css | 73 +++++++++++--- .../components/forms/domain-input.blade.php | 76 +++++++++++++++ .../views/components/icon-tooltip.blade.php | 6 +- .../views/components/popup-small.blade.php | 29 ++++-- .../components/server-timing-hud.blade.php | 26 ++--- .../views/livewire/global-search.blade.php | 38 ++++++-- .../project/application/domains.blade.php | 96 +++---------------- .../project/application/general.blade.php | 5 +- .../application/partials/domain-row.blade.php | 75 ++++++++++++++- .../project/service/domains.blade.php | 60 +++--------- .../service/partials/domain-table.blade.php | 76 ++++++++++++++- .../shared/configuration-checker.blade.php | 3 +- .../views/livewire/server/new/by-ip.blade.php | 10 +- .../livewire/source/gitlab/change.blade.php | 35 +++---- tests/Feature/AddServerTimingHeadersTest.php | 7 +- tests/Feature/ApplicationDomainsTest.php | 68 ++++++++++--- .../Feature/ApplicationGeneralLayoutTest.php | 17 +++- tests/Feature/GlobalIconTooltipTest.php | 10 ++ .../Feature/GlobalSearchLoadingStateTest.php | 29 +++++- .../Livewire/ConfigurationCheckerTest.php | 5 +- tests/Feature/NativeSelectMigrationTest.php | 21 ++++ .../Feature/RealtimeTerminalPackagingTest.php | 7 ++ .../ServerCreationBuildRoleLayoutTest.php | 6 +- tests/Feature/ServiceDomainsTest.php | 49 +++++++--- tests/Unit/DomainUrlPartsTest.php | 30 ++++++ 30 files changed, 693 insertions(+), 292 deletions(-) create mode 100644 .ai/lessons.md create mode 100644 app/Support/DomainUrlParts.php create mode 100644 resources/views/components/forms/domain-input.blade.php create mode 100644 tests/Feature/NativeSelectMigrationTest.php create mode 100644 tests/Unit/DomainUrlPartsTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md new file mode 100644 index 0000000000..0c08f5d495 --- /dev/null +++ b/.ai/lessons.md @@ -0,0 +1,7 @@ +# Lessons + +## Alpine x-transition + tw-animate-css exit animations flash at the end +- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears. +- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity. +- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`. +- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one. diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 07e9808089..c503dc9472 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -41,10 +41,6 @@ class Domains extends Component public string $editingDomain = ''; - public string $editingIndexing = 'index'; - - public string $editingDirection = 'both'; - public ?string $editingService = null; /** @var array */ @@ -103,8 +99,6 @@ class Domains extends Component return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), - 'editingIndexing' => 'string|in:index,noindex', - 'editingDirection' => 'string|in:both,www,non-www', 'redirect' => 'string|required|in:both,www,non-www', 'serviceRedirects' => 'array', 'serviceRedirects.*' => 'string|in:both,www,non-www', @@ -151,6 +145,12 @@ class Domains extends Component $this->dispatch('success', 'Search engine indexing updated.'); } + public function updateRedirect(string $redirect): void + { + $this->redirect = $redirect; + $this->setRedirect(); + } + public function loadDomainState(): void { $this->application->refresh(); @@ -912,8 +912,6 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingService = $this->domainRows[$index]['service']; - $this->editingDirection = $this->serviceRedirectFor($this->editingService); - $this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index'; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; @@ -996,8 +994,6 @@ class Domains extends Component $this->editingIndex = null; $this->editingDomain = ''; $this->editingService = null; - $this->editingDirection = 'both'; - $this->editingIndexing = 'index'; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); if ($this->pendingAction === 'update') { @@ -1040,6 +1036,7 @@ class Domains extends Component $newUrl = $this->splitDomains($normalized)[0]; $oldUrl = $this->domainRows[$this->editingIndex]['url']; $service = $this->editingService; + $wasNoindexed = $this->application->isDomainNoindexed($oldUrl); $current = $this->currentDomainList($service); if ($newUrl !== $oldUrl && $current->contains($newUrl)) { @@ -1066,20 +1063,13 @@ class Domains extends Component } $noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); - if ($this->editingIndexing === 'noindex') { + if ($wasNoindexed) { $noindexDomains->push($newUrl); } $this->application->setNoindexDomains($noindexDomains); $this->application->save(); $this->resetDefaultLabels(); - if ($this->isCompose && filled($service) && $this->editingDirection !== $this->savedRedirectForService($service)) { - $this->serviceRedirects[$this->serviceRedirectWireKey($service)] = $this->editingDirection; - $this->notifyRedirectUpdate = false; - $this->setServiceRedirect($service); - $this->notifyRedirectUpdate = true; - } - $this->forceSaveDomains = false; $this->pendingAction = null; $this->cancelEdit(); @@ -1237,6 +1227,12 @@ class Domains extends Component } } + public function updateServiceRedirect(string $serviceName, string $redirect): void + { + $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $redirect; + $this->setServiceRedirect($serviceName); + } + /** * @param mixed ...$modalArgs Extra args from modal-confirmation (password, etc.) */ diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 9a05d72ed9..eb2d68c822 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -7,6 +7,7 @@ use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -46,10 +47,6 @@ class Domains extends Component public string $editingDomain = ''; - public string $editingDirection = 'both'; - - public string $editingIndexing = 'index'; - public ?int $editingServiceApplicationId = null; public bool $showEditDomainModal = false; @@ -102,8 +99,6 @@ class Domains extends Component return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), - 'editingDirection' => 'string|in:both,www,non-www', - 'editingIndexing' => 'string|in:index,noindex', 'newServiceApplicationId' => 'nullable|integer', 'serviceRedirects' => 'array', 'serviceRedirects.*' => 'string|in:both,www,non-www', @@ -924,9 +919,6 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; - $app = $this->findServiceApp($this->editingServiceApplicationId); - $this->editingDirection = $this->normalizeRedirect($app?->redirect); - $this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index'; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; @@ -940,8 +932,6 @@ class Domains extends Component $this->editingIndex = null; $this->editingDomain = ''; $this->editingServiceApplicationId = null; - $this->editingDirection = 'both'; - $this->editingIndexing = 'index'; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; @@ -974,6 +964,7 @@ class Domains extends Component $newUrl = $this->splitDomains($normalized)[0]; $oldUrl = $this->domainRows[$this->editingIndex]['url']; $current = collect($this->splitDomains($app->fqdn)); + $wasNoindexed = $app->isDomainNoindexed($oldUrl); if ($newUrl !== $oldUrl && $current->contains($newUrl)) { $this->addError('editingDomain', "Domain {$newUrl} is already configured for this service."); @@ -1000,18 +991,12 @@ class Domains extends Component } $noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); - if ($this->editingIndexing === 'noindex') { + if ($wasNoindexed) { $noindexDomains->push($newUrl); } $app->setNoindexDomains($noindexDomains); $app->save(); - if ($this->editingDirection !== $this->normalizeRedirect($app->redirect)) { - $this->notifyRedirectUpdate = false; - $this->updateServiceRedirect((int) $app->id, $this->editingDirection); - $this->notifyRedirectUpdate = true; - } - $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->forceSaveDomains = false; @@ -1140,12 +1125,9 @@ class Domains extends Component $domain = generateUrl(server: $server, random: new_public_id()); $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null) { - $parts = parse_url($domain); - if (is_array($parts) && empty($parts['port'])) { - $scheme = $parts['scheme'] ?? 'https'; - $host = $parts['host'] ?? ''; - $path = $parts['path'] ?? ''; - $domain = "{$scheme}://{$host}:{$requiredPort}{$path}"; + $parts = DomainUrlParts::split($domain); + if ($parts['port'] === '') { + $domain = DomainUrlParts::compose($parts['scheme'], $parts['host'], (string) $requiredPort, $parts['path']); } } diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index bb3991f220..aa26071020 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -69,6 +69,9 @@ class ExecuteContainerCommand extends Component $this->type = 'service'; $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->resource); + if (! $this->resource->isRunning()) { + $this->containersLoaded = true; + } if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php new file mode 100644 index 0000000000..2e6da7868c --- /dev/null +++ b/app/Support/DomainUrlParts.php @@ -0,0 +1,56 @@ + in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) + ? strtolower($parts['scheme']) + : 'https', + 'host' => (string) $parts['host'], + 'port' => isset($parts['port']) ? (string) $parts['port'] : '', + 'path' => $path, + ]; + } + + /** + * @return array{scheme: string, host: string, port: string, path: string} + */ + public static function empty(): array + { + return ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + } +} diff --git a/resources/css/app.css b/resources/css/app.css index 3d0b7cb5a1..1d73bf3280 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2114,17 +2114,21 @@ input[type="search"]::-webkit-search-results-decoration { } .domains-table-grid { - grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 6.5rem; + grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; } .domains-table-grid-compose { - grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem minmax(7rem, 0.9fr) 6.5rem; + grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; +} + +.domains-mobile-label { + display: none; } /* Domains table: collapse secondary columns on tablet/phone */ @media (max-width: 900px) { .domains-table-grid { - grid-template-columns: minmax(0, 1fr) 8.25rem 5.5rem; + grid-template-columns: minmax(0, 1fr) 8.25rem 9rem 10rem 5.5rem; gap: 0.75rem; } @@ -2134,7 +2138,7 @@ input[type="search"]::-webkit-search-results-decoration { } .domains-table-grid-compose { - grid-template-columns: minmax(0, 1fr) 8.25rem 5.5rem; + grid-template-columns: minmax(0, 1fr) 8.25rem 9rem 10rem 5.5rem; gap: 0.75rem; } @@ -2145,7 +2149,7 @@ input[type="search"]::-webkit-search-results-decoration { } } -@media (max-width: 640px) { +@media (max-width: 768px) { .data-table-header.domains-table-grid, .data-table-header.domains-table-grid-compose { display: none; @@ -2156,14 +2160,28 @@ input[type="search"]::-webkit-search-results-decoration { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: - 'domain actions' - 'meta actions'; - gap: 0.35rem 0.75rem; + 'domain domain' + 'meta actions' + 'indexing indexing' + 'direction direction'; + gap: 0.5rem 0.75rem; align-items: start; - padding: 0.875rem 1rem; + padding: 0.75rem; min-height: 0; } + .data-table-row.domains-row-without-direction { + grid-template-areas: + 'domain domain' + 'meta actions' + 'indexing indexing'; + } + + .data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(5), + .data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(6) { + display: none; + } + /* Domain cell */ .data-table-row.domains-table-grid > :nth-child(1), .data-table-row.domains-table-grid-compose > :nth-child(1) { @@ -2178,7 +2196,7 @@ input[type="search"]::-webkit-search-results-decoration { word-break: break-word; } - /* Non-compose: 1 Domain, 2 DNS, 3 Last checked, 4 Actions */ + /* Non-compose: 1 Domain, 2 DNS, 3 Last checked, 4 Indexing, 5 Direction, 6 Actions */ .data-table-row.domains-table-grid > :nth-child(2) { grid-area: meta; display: flex !important; @@ -2192,11 +2210,19 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid > :nth-child(4) { + grid-area: indexing; + } + + .data-table-row.domains-table-grid > :nth-child(5) { + grid-area: direction; + } + + .data-table-row.domains-table-grid > :nth-child(6) { grid-area: actions; align-self: center; } - /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Last checked, 5 Actions */ + /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Last checked, 5 Indexing, 6 Direction, 7 Actions */ .data-table-row.domains-table-grid-compose > :nth-child(2) { display: none !important; } @@ -2214,10 +2240,33 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid-compose > :nth-child(5) { + grid-area: indexing; + } + + .data-table-row.domains-table-grid-compose > :nth-child(6) { + grid-area: direction; + } + + .data-table-row.domains-table-grid-compose > :nth-child(7) { grid-area: actions; align-self: center; } + .domains-mobile-label { + display: block; + margin-bottom: 0.25rem; + font-size: 0.6875rem; + font-weight: 500; + line-height: 1rem; + color: var(--coollabs-subtle); + } + + .data-table-row.domains-table-grid .listbox-trigger, + .data-table-row.domains-table-grid-compose .listbox-trigger { + height: 2rem; + font-size: 0.8125rem; + } + /* Service name as badge under domain on mobile (compose only) */ .data-table-row.domains-table-grid-compose .domains-service-mobile { display: inline-flex !important; @@ -2228,7 +2277,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } -@media (max-width: 640px) { +@media (max-width: 768px) { .domains-service-desktop { display: none !important; } diff --git a/resources/views/components/forms/domain-input.blade.php b/resources/views/components/forms/domain-input.blade.php new file mode 100644 index 0000000000..482788906b --- /dev/null +++ b/resources/views/components/forms/domain-input.blade.php @@ -0,0 +1,76 @@ +@props([ + 'id', + 'wire' => true, + 'value' => '', + 'errorId' => null, +]) + +
whereStartsWith('x-model') }}> +
+ +
+ +
+ + + @error($errorId ?? $id) +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + +

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

+
+
diff --git a/resources/views/components/icon-tooltip.blade.php b/resources/views/components/icon-tooltip.blade.php index 52b1af7f72..2ca21a9bf7 100644 --- a/resources/views/components/icon-tooltip.blade.php +++ b/resources/views/components/icon-tooltip.blade.php @@ -38,11 +38,11 @@ this.visible = true; const rect = target.getBoundingClientRect(); this.below = rect.top < 48; - this.x = rect.left + rect.width / 2; + this.x = rect.left; this.y = this.below ? rect.bottom + 8 : rect.top - 8; this.$nextTick(() => { const width = this.$refs.tooltip?.offsetWidth || 0; - this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x)); + this.x = Math.max(8, Math.min(window.innerWidth - width - 8, this.x)); this.$nextTick(() => this.positioned = true); }); }, @@ -71,6 +71,6 @@ diff --git a/resources/views/components/popup-small.blade.php b/resources/views/components/popup-small.blade.php index 329222e350..fca570f344 100644 --- a/resources/views/components/popup-small.blade.php +++ b/resources/views/components/popup-small.blade.php @@ -50,14 +50,23 @@ x-transition:leave="transition ease-in duration-150" x-transition:leave-start="translate-y-0 opacity-100" x-transition:leave-end="translate-y-3 opacity-0" - class="fixed bottom-4 right-4 z-999" - :class="iconOnly - ? 'w-auto max-w-[calc(100%-2rem)]' - : (compact - ? 'w-[calc(100%-2rem)] sm:w-auto sm:max-w-[calc(100%-2rem)]' - : 'w-[calc(100%-2rem)] max-w-sm')"> -
+ + +
@isset($icon)
@endisset -
+

{{ $title }}

@@ -76,7 +85,7 @@
- diff --git a/resources/views/components/server-timing-hud.blade.php b/resources/views/components/server-timing-hud.blade.php index a112616ff0..35fad6a372 100644 --- a/resources/views/components/server-timing-hud.blade.php +++ b/resources/views/components/server-timing-hud.blade.php @@ -176,20 +176,12 @@ const toggle = qs(root, '[data-sth-toggle]'); if (toggle) { - if (isMobileSlot) { - // Compact pill: just "83ms" — no full metric breakdown in the bar. - toggle.style.padding = '4px 8px'; - toggle.style.fontSize = '11px'; - toggle.style.maxWidth = 'none'; - toggle.style.overflow = 'visible'; - toggle.style.whiteSpace = 'nowrap'; - } else { - toggle.style.padding = ''; - toggle.style.fontSize = ''; - toggle.style.maxWidth = '100%'; - toggle.style.overflow = 'hidden'; - toggle.style.textOverflow = 'ellipsis'; - } + // Keep the navbar pill compact at every breakpoint; details stay in the panel. + toggle.style.padding = '4px 8px'; + toggle.style.fontSize = '11px'; + toggle.style.maxWidth = 'none'; + toggle.style.overflow = 'visible'; + toggle.style.whiteSpace = 'nowrap'; } const panel = qs(root, '[data-sth-panel]'); @@ -680,10 +672,8 @@ const q = latest.metrics.queries !== undefined ? Math.round(Number(latest.metrics.queries)) + 'q' : '—'; const db = latest.metrics.db !== undefined ? Number(latest.metrics.db).toFixed(0) + 'ms db' : '—'; const n = history.length; - // Mobile navbar is tight — show only total app time; full breakdown lives in the panel. - const compactSummary = root.getAttribute('data-sth-mode') === 'docked' - && root.parentElement - && root.parentElement.id === 'server-timing-hud-slot-mobile'; + // Navbar pills show only total app time; full breakdown lives in the panel. + const compactSummary = root.getAttribute('data-sth-mode') === 'docked'; summary.textContent = compactSummary ? app : ('ST ' + app + ' · ' + db + ' · ' + q + (n > 1 ? ' · ×' + n : '')); diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index cd57aaeae5..43d6e6b55a 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -3,6 +3,9 @@ selectedIndex: -1, isSearching: false, isLoadingInitialData: false, + showLoadingSpinner: false, + spinnerTimer: null, + closeResetTimer: null, isPaletteTransitioning: false, allSearchableItems: [], searchQuery: '', @@ -97,29 +100,46 @@ console.warn('Global search: $wire not available, skipping open'); return; } + clearTimeout(this.closeResetTimer); + clearTimeout(this.spinnerTimer); this.modalOpen = true; this.selectedIndex = -1; this.isLoadingInitialData = true; + this.showLoadingSpinner = false; this.searchQuery = ''; + // Only show the spinner when loading takes longer than 150ms, so fast (cached) loads do not flash the icon + this.spinnerTimer = setTimeout(() => { + if (this.isLoadingInitialData) this.showLoadingSpinner = true; + }, 150); $wire.openSearchModal().then(() => { this.allSearchableItems = $wire.allSearchableItems || []; this.creatableItems = $wire.creatableItems || []; + clearTimeout(this.spinnerTimer); this.isLoadingInitialData = false; + this.showLoadingSpinner = false; setTimeout(() => this.$refs.searchInput?.focus(), 50); }).catch(() => { // Handle case where component was destroyed during navigation + clearTimeout(this.spinnerTimer); this.modalOpen = false; this.isLoadingInitialData = false; + this.showLoadingSpinner = false; }); }, closeModal() { this.modalOpen = false; this.selectedIndex = -1; this.isSearching = false; - this.isLoadingInitialData = false; - this.searchQuery = ''; - this.allSearchableItems = []; - this.isPaletteTransitioning = false; + // Keep the palette content intact until the leave animation (100ms) ends, + // otherwise the panel collapses to header height while it fades out + clearTimeout(this.closeResetTimer); + this.closeResetTimer = setTimeout(() => { + this.isLoadingInitialData = false; + this.showLoadingSpinner = false; + this.searchQuery = ''; + this.allSearchableItems = []; + this.isPaletteTransitioning = false; + }, 150); }, runPaletteTransition(callback) { this.isPaletteTransitioning = true; @@ -312,24 +332,24 @@ class="fixed inset-0 z-99 flex items-start justify-center px-4 pt-[12vh]">
- - + -
-
- -
- @can('update', $application) -
- -
- @endcan -
- @endif - @endif - {{-- Toolbar --}} @@ -153,16 +116,15 @@
@if ($isCompose && count($composeServices) > 0) - - @foreach ($composeServices as $serviceName) - - @endforeach - + @endif - + @if ($addDomainDnsFailed) @@ -252,6 +214,8 @@ Domain DNS Last checked + Search engine indexing + Direction
@foreach ($rows as $row) @@ -287,6 +251,8 @@ Domain DNS Check Last checked + Search engine indexing + Direction
@foreach ($domainRows as $index => $row) @@ -340,40 +306,8 @@
-
-
- -
- -

- Full URL including scheme. Optional path and container port are supported. -

- @error('editingDomain') -

{{ $message }}

- @enderror -
- - @unless ($labelsAreWritable) -
- @if ($isCompose) - - @endif - -
- @endunless + @if ($editDomainDnsFailed) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index b3775a1ddc..7384067f98 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -6,7 +6,8 @@ } }"> - +
@if ($buildPack === 'dockercompose') @@ -302,7 +303,7 @@ @endif @if ($buildPack === 'dockercompose')
-
+

Docker Compose

diffForHumans() : null; $gridClass = ($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid'; + $domainParts = $isSuggested ? null : parse_url($row['url']); + $faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host']) + ? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico' + : null; + $redirectPairKey = function (string $url): string { + $parts = parse_url($url); + if (! is_array($parts) || ! isset($parts['host'])) { + return $url; + } + + $host = preg_replace('/^www\./i', '', $parts['host']); + + return strtolower(($parts['scheme'] ?? '').'://'.$host.':'.($parts['port'] ?? '').($parts['path'] ?? '')); + }; + $pairKey = $redirectPairKey($row['url']); + $firstPairRowIndex = collect($domainRows) + ->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false)) + ->filter(fn ($item) => ($item['service'] ?? null) === ($row['service'] ?? null)) + ->filter(fn ($item) => $redirectPairKey($item['url']) === $pairKey) + ->keys() + ->first(); + $showDirection = ! $isSuggested && $firstPairRowIndex === $index; @endphp
$isSuggested, + 'domains-row-without-direction' => ! $showDirection, ])>
@@ -35,6 +58,11 @@ {{ $row['url'] }} @else + @if ($faviconUrl) + + @endif @@ -78,6 +106,51 @@ {{ $checkedAt ?: '-' }}
+
+ @unless ($isSuggested) + Search engine indexing + @endunless + @if ($isSuggested) + - + @elseif (auth()->user()?->can('update', $application) && ! $labelsAreWritable) + + @else + + {{ $application->isDomainNoindexed($row['url']) ? 'Noindex' : 'Indexable' }} + + @endif +
+ +
+ @php + $rowDirection = $domainDirection ?? $redirect; + $directionLabel = match ($rowDirection) { + 'www' => 'Redirect to www', + 'non-www' => 'Redirect to non-www', + default => 'Allow both', + }; + @endphp + @if ($showDirection) + Direction + @endif + @if ($showDirection && auth()->user()?->can('update', $application) && ! $labelsAreWritable) + + @elseif ($showDirection) + {{ $directionLabel }} + @endif +
+
@can('update', $application)
-
-
- -
- - @error('editingDomain') -

{{ $message }}

- @enderror -
- -
- - -
+ @if ($editDomainDnsFailed) diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 48bd1658ec..1717ff4cef 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -13,6 +13,8 @@ @endif DNS Last checked + Search engine indexing + Direction
@endif @@ -43,6 +45,25 @@ $serviceLabel = filled($row['service_name'] ?? null) ? \Illuminate\Support\Str::headline($row['service_name']) : '-'; + $domainParts = $isSuggested ? null : parse_url($row['url']); + $faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host']) + ? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico' + : null; + $redirectPairKey = function (string $url): string { + $parts = parse_url($url); + if (! is_array($parts) || ! isset($parts['host'])) { + return $url; + } + + $host = preg_replace('/^www\./i', '', $parts['host']); + + return strtolower(($parts['scheme'] ?? '').'://'.$host.':'.($parts['port'] ?? '').($parts['path'] ?? '')); + }; + $pairKey = $redirectPairKey($row['url']); + $firstPairRowUrl = collect($rows) + ->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false)) + ->first(fn ($item) => $redirectPairKey($item['url']) === $pairKey)['url'] ?? null; + $showDirection = ! $isSuggested && $firstPairRowUrl === $row['url']; @endphp
$isSuggested, + 'domains-row-without-direction' => ! $showDirection, ])>
+
+ @unless ($isSuggested) + Search engine indexing + @endunless + @if ($isSuggested) + - + @elseif (auth()->user()?->can('update', $service)) + + @else + + {{ $service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'Noindex' : 'Indexable' }} + + @endif +
+ +
+ @php + $rowDirection = $serviceRedirects[$row['service_application_id']] ?? 'both'; + $directionLabel = match ($rowDirection) { + 'www' => 'Redirect to www', + 'non-www' => 'Redirect to non-www', + default => 'Allow both', + }; + @endphp + @if ($showDirection) + Direction + @endif + @if ($showDirection && auth()->user()?->can('update', $service)) + + @elseif ($showDirection) + {{ $directionLabel }} + @endif +
+
@can('update', $service)
- - @if ($fqdn) - - @endif - @if ($ipv4) - - @endif - @if ($ipv6) - - @endif - @if (config('app.url')) - - @endif - + helper="GitLab will use this endpoint unless custom mode is enabled." + :options="collect([$fqdn, $ipv4, $ipv6, config('app.url')]) + ->filter()->unique()->map(fn ($endpoint) => [ + 'value' => $endpoint, + 'label' => 'Use '.$endpoint, + ])->values()->all()" />
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 () { diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 96de79b521..309e69b9c8 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -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('Direction') + ->toContain('Search engine indexing') + ->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('toContain('not->toContain('placeholder="https://app.example.com"') + ->and($component) + ->toContain('Protocol') + ->toContain('Domain') + ->toContain('Port') + ->toContain('Path') + ->toContain("scheme: 'https'") + ->toContain('not->toContain('