diff --git a/.ai/lessons.md b/.ai/lessons.md new file mode 100644 index 000000000..0c08f5d49 --- /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 07e980808..c503dc947 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 9a05d72ed..eb2d68c82 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 bb3991f22..aa2607102 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 000000000..2e6da7868 --- /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 3d0b7cb5a..1d73bf328 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 000000000..482788906 --- /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 52b1af7f7..2ca21a9bf 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 329222e35..fca570f34 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 a112616ff..35fad6a37 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 cd57aaeae..43d6e6b55 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 b3775a1dd..7384067f9 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 48bd1658e..1717ff4ce 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 96de79b52..309e69b9c 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('