From 8a03647a705a1dd981dd5be14c96ac0fd75851c0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:24:46 +0200 Subject: [PATCH] feat(domains): compact mobile domain rows and isolate preview modals Show routing summaries on domain tables below 600px instead of squeezing desktop columns. Scope preview add/edit Livewire events by preview id, authorize preview domain actions, and add search plus unsaved-edit handling. --- DESIGN.md | 9 + .../Project/Application/PreviewDomains.php | 45 ++- resources/css/app.css | 59 ++++ .../application/partials/domain-row.blade.php | 17 +- .../application/preview-domains.blade.php | 263 ++++++++++++------ .../service/partials/domain-table.blade.php | 17 +- tests/Feature/ApplicationDomainsTest.php | 8 +- .../PreviewDomainPortOverridesTest.php | 221 ++++++++++++++- tests/Feature/ServiceDomainsTest.php | 10 + .../Browser/ApplicationConfigurationTest.php | 64 +++++ 10 files changed, 604 insertions(+), 109 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b27ed68f2..86550bd0f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -570,6 +570,15 @@ ## 7. Dense tables --- +### Domain rows on mobile + +Domain tables become compact summary cards below 600px. Keep the public URL on +its own line, followed by a short routing summary such as `HTTP → HTTPS · Port +80 · Noindex`. Put DNS status and the existing icon actions on the final row. +Do not squeeze desktop label/value columns into a mobile card or move settings +behind an overflow menu. Long domains wrap, and icon actions retain 40px touch +targets. + ## 8. Modals, confirmations, and toasts ### Modals diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 2f0ee91ab..cba3f18b6 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -38,6 +38,7 @@ class PreviewDomains extends Component public function mount(): void { + $this->authorize('view', $this->preview->application); $this->refreshDomains(); if ($this->preview->application->build_pack === 'dockercompose') { $this->newDomainService = $this->composeServices()[0] ?? null; @@ -94,7 +95,7 @@ public function addDomain(): void ? ($this->composeServices()[0] ?? null) : null; $this->forceUseUnknownPort = false; - $this->dispatch('close-modal'); + $this->dispatch('close-preview-domain-add', previewId: $this->preview->id); try { $server = $this->preview->application->destination?->server; @@ -149,6 +150,7 @@ public function generateDomain(): void public function startEdit(int $index): void { + $this->authorize('update', $this->preview->application); if (! isset($this->domainRows[$index])) { return; } @@ -159,7 +161,8 @@ public function startEdit(int $index): void if (filled($savedPort)) { $this->editingDomainParts['port'] = (string) $savedPort; } - $this->dispatch('open-preview-domain-edit'); + $this->resetErrorBag('editingDomainParts.host'); + $this->dispatch('open-preview-domain-edit', previewId: $this->preview->id); } public function updateDomain(): void @@ -193,7 +196,7 @@ public function updateDomain(): void return; } $this->forceUseUnknownPort = false; - $this->dispatch('close-preview-domain-edit'); + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); $this->dispatch('success', 'Domain updated.'); $this->checkDomainDns($index); } @@ -229,6 +232,12 @@ public function removeDomain(int $index): void if (! isset($this->domainRows[$index])) { return; } + if ($this->editingIndex === $index) { + $this->editingIndex = null; + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); + } elseif ($this->editingIndex !== null && $this->editingIndex > $index) { + $this->editingIndex--; + } unset($this->domainRows[$index]); $this->domainRows = array_values($this->domainRows); if (! $this->persistDomains()) { @@ -268,6 +277,7 @@ public function checkDomainDns(int $index): void public function pollDnsChecks(): void { + $this->authorize('view', $this->preview->application); $checkingRows = collect($this->domainRows) ->where('dns_status', 'checking') ->values(); @@ -321,6 +331,7 @@ private function checkUrlDns(string $url, string $key = 'domain'): array private function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; $this->preview->refresh(); $statuses = $this->preview->domain_dns_statuses ?? []; $rows = []; @@ -336,6 +347,11 @@ private function refreshDomains(): void } } $this->domainRows = $rows; + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && $row['service'] === $editingRow['service']); + $this->editingIndex = $index === false ? null : (int) $index; + } } private function persistDomains(): bool @@ -349,13 +365,16 @@ private function persistDomains(): bool return false; } - $domains = collect($composeServices) - ->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']]) - ->all(); + $existingDomains = json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: []; + $domains = []; + foreach ($composeServices as $service) { + $domains[$service] = is_array($existingDomains[$service] ?? null) ? $existingDomains[$service] : []; + $domains[$service]['domain'] = ''; + } $validRows = collect($this->domainRows) ->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true)); foreach ($validRows->groupBy('service') as $service => $rows) { - $domains[$service] = ['domain' => $rows->pluck('url')->implode(',')]; + $domains[$service]['domain'] = $rows->pluck('url')->implode(','); } $this->preview->docker_compose_domains = json_encode($domains); $this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null; @@ -440,10 +459,22 @@ private function makeRow(string $url, ?string $service, array $statuses = []): a { $status = $statuses[$this->statusKey($url, $service)] ?? []; $port = $this->effectiveDomainInternalPort($url, $service); + $redirect = 'both'; + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { + $usesPreviewRedirect = (int) $this->preview->application->compose_parsing_version >= 3; + $domains = json_decode(($usesPreviewRedirect + ? $this->preview->docker_compose_domains + : $this->preview->application->docker_compose_domains) ?: '[]', true) ?: []; + $storedRedirect = $usesPreviewRedirect + ? ($domains[$service]['redirect'] ?? null) + : data_get($domains, "$service.redirect"); + $redirect = in_array($storedRedirect, ['www', 'non-www', 'both'], true) ? $storedRedirect : 'both'; + } return [ 'url' => $url, 'service' => $service, + 'redirect' => $redirect, 'internal_port' => $port['internal_port'], 'has_port_override' => $port['has_port_override'], 'dns_status' => $status['status'] ?? 'pending', diff --git a/resources/css/app.css b/resources/css/app.css index e3e20cf75..199f904d0 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4428,6 +4428,10 @@ .service-domain-detail-label { display: none; } +.service-domain-mobile-summary { + display: none; +} + .service-domains-https .listbox-trigger { min-width: 7rem; } @@ -4456,3 +4460,58 @@ @container service-domains (max-width: 980px) { color: var(--coollabs-fg-dim); } } + +@container service-domains (max-width: 600px) { + .data-table-row.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.625rem 0.75rem; + padding: 0.875rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-column: 1 / -1; + } + + .data-table-row.service-domains-overview-grid > :first-child a, + .data-table-row.service-domains-overview-grid > :first-child span[title] { + overflow: visible; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; + } + + .service-domain-detail, + .domains-service-desktop { + display: none; + } + + .service-domain-mobile-summary { + display: flex; + grid-column: 1 / -1; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem 0.75rem; + color: var(--coollabs-fg-dim); + font-size: 12px; + line-height: 1.25rem; + } + + .service-domain-mobile-summary > span:not(:last-child)::after { + margin-left: 0.75rem; + color: var(--coollabs-line); + content: "·"; + } + + .service-domain-dns { + justify-self: start; + } + + .service-domain-actions { + justify-self: end; + } + + .service-domain-actions .icon-button { + width: 2.5rem; + height: 2.5rem; + } +} diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index da06c9146..bcacb44dc 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -100,7 +100,20 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr -