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:
parent
d75881fa96
commit
8a03647a70
10 changed files with 604 additions and 109 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,13 +68,22 @@
|
|||
</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)
|
||||
@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',
|
||||
|
|
@ -61,42 +92,69 @@
|
|||
default => 'neutral',
|
||||
};
|
||||
$dnsLabel = match ($row['dns_status']) {
|
||||
'ok' => 'DNS OK',
|
||||
'ok' => 'DNS matches',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
default => 'DNS pending',
|
||||
'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']) }}" class="env-table-item">
|
||||
<div class="data-table-row domains-table-grid-service">
|
||||
<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 flex-wrap items-center gap-2">
|
||||
<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"
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<div class="flex min-w-0 items-center">
|
||||
|
||||
<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="hidden"></div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<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"
|
||||
|
|
@ -108,7 +166,7 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
|
|||
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">
|
||||
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?"
|
||||
|
|
@ -133,26 +191,47 @@ class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 da
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue