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.
This commit is contained in:
Andras Bacsai 2026-09-07 20:24:46 +02:00
parent d75881fa96
commit 8a03647a70
10 changed files with 604 additions and 109 deletions

View file

@ -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

View file

@ -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',

View file

@ -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;
}
}

View file

@ -100,7 +100,20 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
</span>
</div>
<div class="flex min-w-0 items-center">
<div class="service-domain-mobile-summary" aria-label="Domain routing summary">
@if (str_starts_with($row['url'], 'https://') && $isForceHttpsEnabled)
<span>HTTP HTTPS</span>
@endif
@if (in_array($rowDirection, ['www', 'non-www'], true))
<span>{{ $rowDirection === 'www' ? 'non-www → www' : 'www → non-www' }}</span>
@elseif (! str_starts_with($row['url'], 'https://') || ! $isForceHttpsEnabled)
<span>No redirects</span>
@endif
<span>Port {{ $row['internal_port'] ?? 'missing' }}</span>
<span>{{ $isNoindexed ? 'Noindex' : 'Indexable' }}</span>
</div>
<div class="service-domain-dns flex min-w-0 items-center">
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@ -110,7 +123,7 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
@endif
</div>
<div class="flex items-center justify-end gap-1">
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $application)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"

View file

@ -1,26 +1,48 @@
<div class="flex flex-col gap-3" x-data="{ editOpen: false }"
@open-preview-domain-edit.window="editOpen = true"
@close-preview-domain-edit.window="editOpen = false">
<div class="domains-overview-container flex flex-col gap-3" x-data="{
editOpen: false,
domainSearch: '',
editingDomainBaseline: null,
get hasAddressChanges() {
return this.editOpen && this.editingDomainBaseline !== null
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
&& !$wire.showPortWarningModal;
},
openEditDomain() {
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
this.editOpen = true;
this.$nextTick(() => this.$refs.editForm.querySelector('input[required]')?.focus());
},
closeEditDomain() { this.editOpen = false; this.editingDomainBaseline = null; },
matchesDomainSearch(value) { return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); },
}"
@open-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) openEditDomain()"
@close-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) closeEditDomain()"
@keydown.escape.window="if (editOpen && !$wire.showPortWarningModal) closeEditDomain()">
@if (collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'))
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@endif
<div class="flex flex-wrap items-center gap-2">
<p class="min-w-0 flex-1 text-[13px] text-neutral-500 dark:text-fg-dim">
<p class="min-w-0 flex-1 truncate text-[13px] text-neutral-500 dark:text-fg-dim">
{{ count($domainRows) }} domain{{ count($domainRows) === 1 ? '' : 's' }}
</p>
@if (count($domainRows) > 0)
<input type="search" x-model="domainSearch" aria-label="Search preview domains"
class="input h-8! w-full sm:w-64!" placeholder="Search services or domains" />
@endif
@can('update', $preview->application)
@if (count($domainRows) > 0)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Recheck DNS
Check all DNS
</x-forms.button>
@endif
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
canGate="update" :canResource="$preview->application">
canGate="update" :canResource="$preview->application"
@close-preview-domain-add.window="if ($event.detail.previewId === {{ $preview->id }}) modalOpen = false">
<x-slot:content>
<button type="button" class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Add
Add domain
</button>
</x-slot:content>
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
@ -46,113 +68,170 @@
</div>
@else
<div class="application-settings-section-body is-flush overflow-visible">
<div class="data-table-header domains-table-grid-service">
<div class="data-table-header service-domains-overview-grid">
<span>Domain</span>
<span>DNS check</span>
<span></span>
<span></span>
<span>Protocol redirect</span>
<span>Domain redirect</span>
<span>Internal port</span>
<span>Search indexing</span>
<span>DNS status</span>
<span class="text-right">Actions</span>
</div>
@foreach ($domainRows as $index => $row)
@php
$dnsType = match ($row['dns_status']) {
'ok' => 'success',
'failed' => 'error',
'skipped' => 'warning',
default => 'neutral',
};
$dnsLabel = match ($row['dns_status']) {
'ok' => 'DNS OK',
'failed' => 'DNS mismatch',
'skipped' => 'DNS skipped',
default => 'DNS pending',
};
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
@endphp
<div wire:key="preview-domain-{{ md5(($row['service'] ?? '') . $row['url']) }}" class="env-table-item">
<div class="data-table-row domains-table-grid-service">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<x-reicon name="globe" class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" />
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">{{ $row['url'] }}</a>
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
<span class="table-badge shrink-0"
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from Ports Exposes' }}">
Internal port {{ $row['internal_port'] }}
</span>
@foreach (collect($domainRows)->groupBy(fn ($row) => $row['service'] ?? '', preserveKeys: true) as $serviceName => $rows)
@if ($isCompose)
<div wire:key="preview-domain-service-{{ md5($serviceName) }}"
x-show="matchesDomainSearch(@js($serviceName.' '.$rows->pluck('url')->implode(' ')))"
class="border-b border-neutral-200 bg-neutral-50 px-4 py-3 text-sm font-medium dark:border-white/10 dark:bg-white/[0.04]">{{ $serviceName }}</div>
@endif
@foreach ($rows as $index => $row)
@php
$dnsType = match ($row['dns_status']) {
'ok' => 'success',
'failed' => 'error',
'skipped' => 'warning',
default => 'neutral',
};
$dnsLabel = match ($row['dns_status']) {
'ok' => 'DNS matches',
'failed' => 'DNS mismatch',
'skipped' => 'DNS skipped',
'checking' => 'Checking DNS...',
'pending' => 'Not checked',
default => 'DNS unknown',
};
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
@endphp
<div wire:key="preview-domain-{{ md5(($row['service'] ?? '') . $row['url']) }}" x-show="matchesDomainSearch(@js(($row['service'] ?? '').' '.$row['url']))" class="env-table-item">
<div class="data-table-row service-domains-overview-grid">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<x-reicon name="globe" class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" />
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank" rel="noopener noreferrer"
class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ getFqdnWithoutPort($row['url']) }}">{{ getFqdnWithoutPort($row['url']) }}</a>
</div>
</div>
<div class="service-domain-detail" title="Protocol redirect">
<span class="service-domain-detail-label">Protocol redirect</span>
<span>{{ str_starts_with($row['url'], 'https://') && $preview->application->isForceHttpsEnabled() ? 'HTTP → HTTPS' : 'Disabled' }}</span>
</div>
<div class="service-domain-detail" title="Domain redirect">
<span class="service-domain-detail-label">Domain redirect</span>
<span>{{ match (($row['redirect'] ?? 'both')) { 'www' => 'non-www → www', 'non-www' => 'www → non-www', default => 'Disabled' } }}</span>
</div>
<div class="service-domain-detail"
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from the application or Compose service port' }}">
<span class="service-domain-detail-label">Internal port</span>
@if (filled($row['internal_port'] ?? null))
<span aria-label="Internal port {{ $row['internal_port'] }}">{{ $row['internal_port'] }}</span>
@else
<span class="table-badge table-badge-danger shrink-0"
title="Set Ports Exposes or a per-domain internal port so the proxy can route this domain.">
No internal port
<span role="img" aria-label="No internal port" title="No internal port. Set Ports Exposes or a per-domain internal port so the proxy can route this domain." class="text-red-500 dark:text-red-400">
<x-reicon name="alert-triangle" class="size-4" />
</span>
@endif
@if (filled($row['service']))
<span class="table-badge shrink-0">{{ $row['service'] }}</span>
@endif
</div>
</div>
<div class="flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
</div>
<div class="hidden"></div>
<div class="flex items-center justify-end gap-1">
@can('update', $preview->application)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
</button>
<button type="button" wire:click="startEdit({{ $index }})"
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
buttonTitle="Remove" isErrorButton
submitAction="removeDomainByKey({{ $domainKey }})"
:actions="[
'This domain will be removed from the preview deployment.',
'Redeploy the preview to apply proxy changes.',
]"
:confirmWithPassword="false" :confirmWithText="false"
step2ButtonText="Remove domain">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Remove domain" aria-label="Remove domain">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endcan
<div class="service-domain-detail">
<span class="service-domain-detail-label">Search indexing</span>
<span role="img" aria-label="Search indexing blocked"
title="Search indexing blocked">
<x-reicon name="x" class="size-4" />
</span>
</div>
<div class="service-domain-mobile-summary" aria-label="Domain routing summary">
@if (str_starts_with($row['url'], 'https://') && $preview->application->isForceHttpsEnabled())
<span>HTTP HTTPS</span>
@endif
@if (in_array($row['redirect'] ?? 'both', ['www', 'non-www'], true))
<span>{{ ($row['redirect'] ?? 'both') === 'www' ? 'non-www → www' : 'www → non-www' }}</span>
@elseif (! str_starts_with($row['url'], 'https://') || ! $preview->application->isForceHttpsEnabled())
<span>No redirects</span>
@endif
<span>Port {{ $row['internal_port'] ?? 'missing' }}</span>
<span>Noindex</span>
</div>
<div class="service-domain-dns flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
</div>
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $preview->application)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
</button>
<button type="button" wire:click="startEdit({{ $index }})"
class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ getFqdnWithoutPort($row['url']) }}">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
buttonTitle="Remove" isErrorButton
submitAction="removeDomainByKey({{ $domainKey }})"
:actions="[
'This domain will be removed from the preview deployment.',
'Redeploy the preview to apply proxy changes.',
]"
:confirmWithPassword="false" :confirmWithText="false"
step2ButtonText="Remove domain">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Remove domain" aria-label="Remove domain">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endcan
</div>
</div>
</div>
</div>
@endforeach
@endforeach
<div x-cloak x-show="domainSearch.trim() && !@js(collect($domainRows)->map(fn ($row) => ($row['service'] ?? '').' '.$row['url'])->values()).some(value => matchesDomainSearch(value))" class="px-4 py-8">
<x-empty size="sm" title="No domains found" description="No service or domain matches your search." icon-name="search" />
</div>
</div>
@endif
<template x-teleport="body">
<div x-show="editOpen" x-cloak class="fixed inset-0 z-99 overflow-y-auto">
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="editOpen = false"></div>
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="closeEditDomain()"></div>
<div class="relative flex min-h-full items-center justify-center p-4">
<div x-show="editOpen" x-trap.inert.noscroll="editOpen"
class="application-settings-form application-settings-section relative w-full max-w-3xl">
data-preview-domain-dialog class="application-settings-form application-settings-section relative w-full max-w-3xl">
<header>
<h3>Edit domain</h3>
<button type="button" @click="editOpen = false" class="icon-button" aria-label="Close">
<h3>Domain settings</h3>
<button type="button" @click="closeEditDomain()" class="icon-button" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
<form x-ref="editForm" wire:submit="updateDomain" class="flex flex-col gap-4">
<template x-if="editOpen">
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges" targets="updateDomain,confirmUseUnknownPort" />
</template>
@if ($editingIndex !== null && filled($domainRows[$editingIndex]['service'] ?? null))
<x-forms.input label="Service" :value="$domainRows[$editingIndex]['service']" readonly />
@endif
<x-forms.domain-input id="editingDomainParts" />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted>Save</x-forms.button>
<div class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<x-forms.listbox id="preview-domain-indexing-{{ $preview->id }}" label="Search engine indexing"
:wire="false" value="noindex" disabled
helper="Preview deployments are always excluded from search indexing."
:options="[['value' => 'noindex', 'label' => 'Noindex']]" />
<x-forms.listbox id="preview-domain-direction-{{ $preview->id }}" label="www redirect"
:wire="false" :value="$editingIndex !== null ? ($domainRows[$editingIndex]['redirect'] ?? 'both') : 'both'" disabled
helper="Read-only preview routing configuration."
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
</form>
</div>

View file

@ -133,7 +133,20 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
</span>
</div>
<div class="flex min-w-0 items-center">
<div class="service-domain-mobile-summary" aria-label="Domain routing summary">
@if (str_starts_with($row['url'], 'https://') && ($forceHttpsRedirects[$row['service_application_id']] ?? true))
<span>HTTP HTTPS</span>
@endif
@if (in_array($rowDirection, ['www', 'non-www'], true))
<span>{{ $rowDirection === 'www' ? 'non-www → www' : 'www → non-www' }}</span>
@elseif (! str_starts_with($row['url'], 'https://') || ! ($forceHttpsRedirects[$row['service_application_id']] ?? true))
<span>No redirects</span>
@endif
<span>Port {{ $row['internal_port'] ?? 'missing' }}</span>
<span>{{ $isNoindexed ? 'Noindex' : 'Indexable' }}</span>
</div>
<div class="service-domain-dns flex min-w-0 items-center">
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@ -143,7 +156,7 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
@endif
</div>
<div class="flex items-center justify-end gap-1">
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $service)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"

View file

@ -1933,10 +1933,12 @@
->toContain('@container service-domains (max-width: 980px)')
->toContain('.service-domain-detail-label')
->toContain('.service-domains-overview-grid')
->toContain('@container service-domains (max-width: 600px)')
->toContain('.service-domain-mobile-summary')
->and($row)
->toContain('service-domain-detail-label')
->toContain('Search indexing')
->toContain('Domain redirect');
->toContain('service-domain-mobile-summary')
->toContain('No redirects')
->toContain('Noindex');
});
it('uses segmented fields when adding and editing application domains', function () {

View file

@ -235,7 +235,7 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
->toBe(['https://two-preview.example.com' => 3000]);
});
it('shows an error badge when a preview domain has no internal port and ports exposes is empty', function () {
it('shows a warning when a preview domain has no internal port and ports exposes is empty', function () {
$this->application->update([
'ports_exposes' => null,
]);
@ -246,8 +246,9 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSee('No internal port')
->assertDontSee('Internal port');
->assertSee('aria-label="No internal port"', false)
->assertSee('Set Ports Exposes or a per-domain internal port')
->assertDontSee('aria-label="Internal port ', false);
});
it('rejects adding a preview domain whose portless URL is already configured', function () {
@ -760,3 +761,217 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
expect($preview->fresh()->domain_port_overrides['https://existing.example.com'] ?? null)->toBe(7070);
});
it('keeps the selected preview domain identity across polling refreshes', function (bool $compose) {
$first = 'https://first-preview.example.com';
$second = 'https://second-preview.example.com';
if ($compose) {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
]);
}
$preview = createPreviewForPortTests($this->application, 140, $compose ? [
'docker_compose_domains' => json_encode(['web' => ['domain' => $first], 'api' => ['domain' => $first]]),
] : ['fqdn' => "$first,$second"]);
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->set('editingDomainParts.host', 'renamed-preview.example.com');
$preview->update($compose ? [
'docker_compose_domains' => json_encode(['api' => ['domain' => $first], 'web' => ['domain' => $first]]),
] : ['fqdn' => "$second,$first"]);
$component->call('pollDnsChecks')
->assertSet('editingIndex', 1)
->assertSet('editingDomainParts.host', 'renamed-preview.example.com')
->call('updateDomain')
->assertHasNoErrors();
$preview->refresh();
if ($compose) {
expect(json_decode($preview->docker_compose_domains, true))
->toMatchArray(['web' => ['domain' => 'https://renamed-preview.example.com'], 'api' => ['domain' => $first]]);
} else {
expect($preview->fqdn)->toBe("$second,https://renamed-preview.example.com");
}
})->with([false, true]);
it('keeps the selected preview domain when deleting an earlier row', function () {
$preview = createPreviewForPortTests($this->application, 141, [
'fqdn' => 'https://first-preview.example.com,https://second-preview.example.com',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 1)
->call('removeDomainByKey', hash('sha256', 'https://first-preview.example.com|'))
->assertSet('editingIndex', 0)
->set('editingDomainParts.host', 'renamed-preview.example.com')
->call('updateDomain')
->assertHasNoErrors();
expect($preview->fresh()->fqdn)->toBe('https://renamed-preview.example.com');
});
it('clears the selected preview domain when it is deleted', function () {
$preview = createPreviewForPortTests($this->application, 142, [
'fqdn' => 'https://first-preview.example.com,https://second-preview.example.com',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->call('removeDomain', 0)
->assertSet('editingIndex', null)
->assertDispatched('close-preview-domain-edit', previewId: $preview->id)
->set('editingDomainParts.host', 'wrong-preview.example.com')
->call('updateDomain');
expect($preview->fresh()->fqdn)->toBe('https://second-preview.example.com');
});
it('scopes preview domain modal events to their preview', function () {
$preview = createPreviewForPortTests($this->application, 143, ['fqdn' => 'https://preview.example.com']);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->assertDispatched('open-preview-domain-edit', previewId: $preview->id)
->set('editingDomainParts.host', 'renamed-preview.example.com')
->call('updateDomain')
->assertDispatched('close-preview-domain-edit', previewId: $preview->id)
->set('newDomainParts.host', 'another-preview.example.com')
->call('addDomain')
->assertDispatched('close-preview-domain-add', previewId: $preview->id)
->assertNotDispatched('close-modal');
});
it('prevents members from opening editable preview domain settings', function () {
$preview = createPreviewForPortTests($this->application, 144, ['fqdn' => 'https://preview.example.com']);
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->actingAs($this->user->fresh());
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSuccessful()
->call('startEdit', 0)
->assertForbidden();
});
it('prevents reading preview domains from another team', function () {
$preview = createPreviewForPortTests($this->application, 145, ['fqdn' => 'https://private-preview.example.com']);
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])->assertForbidden();
});
it('rechecks preview view permission before polling domains', function () {
$preview = createPreviewForPortTests($this->application, 146, ['fqdn' => 'https://private-preview.example.com']);
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview]);
$this->team->members()->detach($this->user->id);
$this->actingAs($this->user->fresh());
$component->call('pollDnsChecks')->assertForbidden();
});
it('reports the readonly preview redirect policy used by proxy labels', function (int $parserVersion, string $expectedRedirect) {
$compose = $parserVersion > 0;
$this->application->update(array_merge(['redirect' => 'www'], $compose ? [
'build_pack' => 'dockercompose',
'compose_parsing_version' => (string) $parserVersion,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [3000]\n",
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://production.example.com', 'redirect' => 'www']]),
] : []));
$previewDomain = $expectedRedirect === 'non-www' ? 'https://www.preview.example.com' : 'https://preview.example.com';
$preview = createPreviewForPortTests($this->application, 147, array_merge([
'fqdn' => $previewDomain,
], $compose ? [
'docker_compose_domains' => json_encode(['web' => [
'domain' => $previewDomain,
'redirect' => 'non-www',
]]),
] : []));
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSet('domainRows.0.redirect', $expectedRedirect);
$labels = $compose
? collect(data_get($this->application->fresh()->parse(147, $preview->id), 'services.web-pr-147.labels'))->implode("\n")
: implode("\n", generateLabelsApplication($this->application->fresh(), $preview->fresh()));
expect($labels)->toContain('X-Robots-Tag=noindex, nofollow');
if ($expectedRedirect === 'both') {
expect($labels)->not->toContain('.redirectregex.');
} else {
expect($labels)->toContain('.redirectregex.');
}
})->with([
'normal previews ignore the production redirect' => [0, 'both'],
'legacy compose previews use the production redirect' => [2, 'www'],
'current compose previews use their own stored redirect' => [3, 'non-www'],
]);
it('clears the previous preview address validation error when opening another domain', function () {
$preview = createPreviewForPortTests($this->application, 148, [
'fqdn' => 'https://first-preview.example.com,https://second-preview.example.com',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->set('editingDomainParts.host', 'not a hostname')
->call('updateDomain')
->assertHasErrors('editingDomainParts.host')
->call('startEdit', 1)
->assertHasNoErrors('editingDomainParts.host');
});
it('inherits preview HTTPS redirect policy while always preventing indexing', function (bool $compose, bool $forceHttps) {
if ($compose) {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '3',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [3000]\n",
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://production.example.com']]),
]);
}
$this->application->settings()->update(['is_force_https_enabled' => $forceHttps]);
$preview = createPreviewForPortTests($this->application, 149, array_merge([
'fqdn' => 'https://preview.example.com',
], $compose ? [
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://preview.example.com']]),
] : []));
$labels = $compose
? collect(data_get($this->application->fresh()->parse(149, $preview->id), 'services.web-pr-149.labels'))->implode("\n")
: implode("\n", generateLabelsApplication($this->application->fresh(), $preview->fresh()));
expect($labels)->toContain('X-Robots-Tag=noindex, nofollow');
expect((bool) preg_match('/\.middlewares=[^\n]*redirect-to-https/', $labels))->toBe($forceHttps);
})->with([false, true])->with([false, true]);
it('preserves readonly compose preview redirects when saving an address', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '3',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
]);
$preview = createPreviewForPortTests($this->application, 150, [
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://www.preview.example.com', 'redirect' => 'non-www'],
'api' => ['domain' => '', 'redirect' => 'www'],
]),
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSet('domainRows.0.redirect', 'non-www')
->call('startEdit', 0)
->set('editingDomainParts.host', 'www.renamed-preview.example.com')
->call('updateDomain')
->assertHasNoErrors()
->assertSet('domainRows.0.redirect', 'non-www');
expect(json_decode($preview->fresh()->docker_compose_domains, true))->toBe([
'web' => ['domain' => 'https://www.renamed-preview.example.com', 'redirect' => 'non-www'],
'api' => ['domain' => '', 'redirect' => 'www'],
]);
});

View file

@ -1011,6 +1011,16 @@
expect($html)->not->toContain('aria-label="More actions for');
});
it('uses the shared mobile domain summary layout', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
expect($view)
->toContain('service-domain-mobile-summary')
->toContain('Domain routing summary')
->toContain('No redirects')
->toContain('Noindex');
});
it('reuses the floating save bar for pending domain address edits', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));

View file

@ -1,5 +1,6 @@
<?php
use App\Models\ApplicationPreview;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -294,3 +295,66 @@
->assertSee('https://www.web.example.com')
->screenshot(filename: 'application-compose-domain-overview');
});
it('uses compact preview domains and opens only the selected preview settings', function (bool $isCompose) {
config()->set('app.maintenance.store', 'array');
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
Cache::forget('instance_settings');
if ($isCompose) {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '3',
'docker_compose_raw' => "services:\n web.api:\n image: nginx:alpine\n expose:\n - '8080'\n",
'docker_compose_domains' => json_encode(['web.api' => ['domain' => 'https://production.example.com']]),
]);
}
foreach ([101, 102] as $number) {
ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => $number,
'pull_request_html_url' => "https://example.com/pull/{$number}",
'fqdn' => $isCompose ? null : "https://preview-{$number}.example.com",
'docker_compose_domains' => $isCompose ? json_encode(['web.api' => ['domain' => "https://preview-{$number}.example.com"]]) : null,
]);
}
loginAndSkipBoarding();
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application).'/preview-deployments';
$page = visit($url);
$page->click('Accept and close')
->click('[aria-label="Settings for https://preview-101.example.com"]')
->assertSee('Domain settings')
->assertVisible('[aria-label="Search indexing blocked"] >> nth=0');
expect($page->script("[...document.querySelectorAll('[data-preview-domain-dialog]')].filter(el => el.getClientRects().length > 0).length"))->toBe(1);
$page->fill('#editingDomainParts-host:visible', 'renamed-preview.example.com')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->screenshot(filename: 'preview-domain-unified-settings')
->click('.is-dirty [wire\\:click="updateDomain"]')
->assertDontSee('Domain settings')
->assertSee('https://renamed-preview.example.com')
->assertSee('https://preview-102.example.com')
->assertNoJavaScriptErrors()
->screenshot(filename: 'preview-domains-compact');
$page->click('[aria-label="Settings for https://preview-102.example.com"]')
->assertSee('Domain settings')
->fill('#editingDomainParts-path:visible', '/discard')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->screenshot(filename: 'preview-domain-before-reset')
->click('.is-dirty button:has-text("Reset")')
->assertDontSee('Domain settings')
->click('[aria-label="Settings for https://preview-102.example.com"]')
->assertValue('#editingDomainParts-path:visible', '')
->click('[data-preview-domain-dialog]:visible [aria-label="Close"]')
->resize(390, 844)
->assertNoJavaScriptErrors()
->screenshot(filename: 'preview-domains-mobile');
expect($page->script('document.documentElement.scrollWidth <= window.innerWidth'))->toBeTrue();
})->with(['regular application' => false, 'Compose application' => true]);
it('declares the compact preview domain layout and shared save bar', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/preview-domains.blade.php'));
expect($view)->toContain('service-domains-overview-grid')
->toContain('service-domain-mobile-summary')
->toContain('Domain routing summary')
->toContain('<x-unsaved-bar action="updateDomain"')
->toContain('$event.detail.previewId');
});