feat(domains): add inline indexing and redirect controls

Move domain indexing and redirect settings into domain management views and include noindex changes in deployment configuration diffs.
This commit is contained in:
Andras Bacsai
2026-08-10 15:10:30 +02:00
parent 8c9a7413d0
commit 69ba001e21
22 changed files with 327 additions and 185 deletions
+2
View File
@@ -24,6 +24,8 @@ compact divided list, not legacy green check SVGs or fixed-width status rows.
> - Build frontend assets in the Vitee container with
> `docker exec coolify-vite npm run build`.
> - Use existing components before adding another styling abstraction.
> - Use `<x-forms.listbox>` for dropdown controls. Never add a native
> `<select>` to a redesigned view, including compact table-row controls.
---
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\Server;
use App\Support\ValidationPatterns;
@@ -38,6 +39,8 @@ class Domains extends Component
public string $editingDomain = '';
public string $editingIndexing = 'index';
public ?string $editingService = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
@@ -96,6 +99,7 @@ class Domains extends Component
return [
'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(),
'editingIndexing' => 'string|in:index,noindex',
'redirect' => 'string|required|in:both,www,non-www',
'serviceRedirects' => 'array',
'serviceRedirects.*' => 'string|in:both,www,non-www',
@@ -126,6 +130,22 @@ class Domains extends Component
$this->loadDomainState();
}
public function toggleNoindexDomain(string $domain, string|bool $indexing): void
{
$this->authorize('update', $this->application);
$noindex = $indexing === true || $indexing === 'noindex';
$domains = $this->application->noindexDomains();
$domains = $noindex ? $domains->push($domain) : $domains->reject(fn (string $item) => $item === $domain);
$this->application->setNoindexDomains($domains);
$this->application->save();
$this->application->refresh();
$this->resetDefaultLabels();
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
$this->dispatch('success', 'Search engine indexing updated.');
}
public function loadDomainState(): void
{
$this->application->refresh();
@@ -887,6 +907,7 @@ class Domains extends Component
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingService = $this->domainRows[$index]['service'];
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true;
@@ -969,6 +990,7 @@ class Domains extends Component
$this->editingIndex = null;
$this->editingDomain = '';
$this->editingService = null;
$this->editingIndexing = 'index';
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
if ($this->pendingAction === 'update') {
@@ -1036,6 +1058,14 @@ class Domains extends Component
return;
}
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
if ($this->editingIndexing === 'noindex') {
$noindexDomains->push($newUrl);
}
$this->application->setNoindexDomains($noindexDomains);
$this->application->save();
$this->resetDefaultLabels();
$this->forceSaveDomains = false;
$this->pendingAction = null;
$this->cancelEdit();
@@ -1162,13 +1192,16 @@ class Domains extends Component
// www / non-www redirects need both hosts configured as real domains so the
// proxy can serve the canonical host and redirect the other. Auto-add missing
// counterparts instead of leaving them as optional suggestions.
$addedDomains = [];
if (in_array($this->redirect, ['www', 'non-www'], true)) {
$domainsBeforePairing = $this->currentDomainList();
if (! $this->ensureWwwNonWwwPairsConfigured(null)) {
return;
}
$this->application->refresh();
$this->application->redirect = $this->redirect;
$addedDomains = $this->currentDomainList()->diff($domainsBeforePairing)->values()->all();
}
$domains = collect($this->application->fqdns);
@@ -1183,6 +1216,7 @@ class Domains extends Component
$this->resetDefaultLabels();
$this->dispatch('success', 'Redirect updated.');
$this->refreshDomains();
$this->checkUrlsDns($addedDomains);
$this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) {
handleError($e, $this);
@@ -1229,12 +1263,15 @@ class Domains extends Component
$this->pendingRedirectService = $serviceName;
// Promote the optional www/non-www suggestion to a real domain for redirects.
$addedDomains = [];
if (in_array($redirect, ['www', 'non-www'], true)) {
$domainsBeforePairing = $this->currentDomainList($serviceName);
if (! $this->ensureWwwNonWwwPairsConfigured($serviceName)) {
return;
}
// Ensure we re-read domains after pair save before writing redirect.
$this->application->refresh();
$addedDomains = $this->currentDomainList($serviceName)->diff($domainsBeforePairing)->values()->all();
}
$allDomains = $this->application->docker_compose_domains
@@ -1268,6 +1305,7 @@ class Domains extends Component
$this->resetDefaultLabels();
$this->dispatch('success', "Redirect updated for {$serviceName}.");
$this->refreshDomains();
$this->checkUrlsDns($addedDomains, $serviceName);
$this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) {
handleError($e, $this);
@@ -29,9 +29,6 @@ class General extends Component
public ?string $fqdn = null;
/** @var array<int, string> */
public array $noindexDomains = [];
public string $gitRepository;
public string $gitBranch;
@@ -148,8 +145,6 @@ class General extends Component
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => ValidationPatterns::applicationDomainRules(),
'noindexDomains' => 'array',
'noindexDomains.*' => 'string',
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch],
@@ -354,7 +349,6 @@ class General extends Component
$this->application->name = $this->name;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->setNoindexDomains($this->noindexDomains);
$this->application->git_repository = $this->gitRepository;
$this->application->git_branch = $this->gitBranch;
$this->application->git_commit_sha = $this->gitCommitSha;
@@ -407,7 +401,6 @@ class General extends Component
$this->name = $this->application->name;
$this->description = $this->application->description;
$this->fqdn = $this->application->fqdn;
$this->noindexDomains = $this->application->noindexDomains()->all();
$this->gitRepository = $this->application->git_repository;
$this->gitBranch = $this->application->git_branch;
$this->gitCommitSha = $this->application->git_commit_sha;
@@ -735,26 +728,6 @@ class General extends Component
$this->submit();
}
public function getConfiguredDomainsProperty(): array
{
return ValidationPatterns::applicationDomainList($this->application->fqdn);
}
public function updateNoindexDomains()
{
$this->authorize('update', $this->application);
try {
$this->application->setNoindexDomains($this->noindexDomains);
$this->application->save();
$this->noindexDomains = $this->application->noindexDomains()->all();
$this->resetDefaultLabels();
$this->dispatch('success', 'Search engine indexing updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function setRedirect()
{
$this->authorize('update', $this->application);
+51 -2
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -17,6 +18,8 @@ class Domains extends Component
use AuthorizesRequests;
use InteractsWithCloudflareDomainConnect;
protected bool $notifyRedirectUpdate = true;
public Service $service;
/** @var array<int, array{id: int, name: string, image: ?string, required_port: ?int}> */
@@ -43,6 +46,10 @@ class Domains extends Component
public string $editingDomain = '';
public string $editingDirection = 'both';
public string $editingIndexing = 'index';
public ?int $editingServiceApplicationId = null;
public bool $showEditDomainModal = false;
@@ -95,6 +102,8 @@ 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',
@@ -114,6 +123,22 @@ class Domains extends Component
$this->loadDomainState();
}
public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void
{
$application = $this->service->applications()->findOrFail($serviceApplicationId);
$this->authorize('update', $application);
$noindex = $indexing === true || $indexing === 'noindex';
$domains = $application->noindexDomains();
$domains = $noindex ? $domains->push($domain) : $domains->reject(fn (string $item) => $item === $domain);
$application->setNoindexDomains($domains);
$application->save();
$this->service->parse();
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
$this->dispatch('success', 'Search engine indexing updated.');
}
public function loadDomainState(): void
{
$this->service->loadMissing(['applications', 'server']);
@@ -614,13 +639,16 @@ class Domains extends Component
$this->serviceRedirects[$serviceApplicationId] = $redirect;
$this->pendingRedirectServiceApplicationId = $serviceApplicationId;
$saved = DB::transaction(function () use ($app, $redirect): bool {
$addedDomains = [];
$saved = DB::transaction(function () use ($app, $redirect, &$addedDomains): bool {
// Promote the optional www/non-www suggestion to a real domain for redirects.
if (in_array($redirect, ['www', 'non-www'], true)) {
$domainsBeforePairing = collect($this->splitDomains($app->fqdn));
if (! $this->ensureWwwNonWwwPairsConfigured($app)) {
return false;
}
$app->refresh();
$addedDomains = collect($this->splitDomains($app->fqdn))->diff($domainsBeforePairing)->values()->all();
}
$domains = collect($this->splitDomains($app->fqdn));
@@ -644,10 +672,13 @@ class Domains extends Component
$this->pendingRedirectServiceApplicationId = null;
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
$this->dispatch('success', 'Redirect updated.');
if ($this->notifyRedirectUpdate) {
$this->dispatch('success', 'Redirect updated.');
}
$this->dispatch('configurationChanged');
$this->pruneDomainDnsStatusesToCurrentDomains();
$this->refreshDomains();
$this->checkUrlsDns($addedDomains, $serviceApplicationId);
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -893,6 +924,9 @@ 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;
@@ -906,6 +940,8 @@ 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;
@@ -963,6 +999,19 @@ class Domains extends Component
return;
}
$noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
if ($this->editingIndexing === 'noindex') {
$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;
@@ -31,39 +31,13 @@ class EditDomain extends Component
#[Validate]
public ?string $fqdn = null;
/** @var array<int, string> */
public array $noindexDomains = [];
protected function rules(): array
{
return [
'fqdn' => ValidationPatterns::applicationDomainRules(),
'noindexDomains' => 'array',
'noindexDomains.*' => 'string',
];
}
public function getConfiguredDomainsProperty(): array
{
return ValidationPatterns::applicationDomainList($this->application->fqdn);
}
public function updateNoindexDomains()
{
try {
$this->authorize('update', $this->application);
$this->application->setNoindexDomains($this->noindexDomains);
$this->application->save();
$this->application->refresh();
$this->syncData();
$this->application->service->parse();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Search engine indexing updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function mount()
{
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
@@ -79,13 +53,11 @@ class EditDomain extends Component
// Sync to model
$this->application->fqdn = $this->fqdn;
$this->application->setNoindexDomains($this->noindexDomains);
$this->application->save();
} else {
// Sync from model
$this->fqdn = $this->application->fqdn;
$this->noindexDomains = $this->application->noindexDomains()->all();
}
}
+1 -1
View File
@@ -1383,7 +1383,7 @@ class Application extends BaseModel
private function legacyConfigurationHash(): string
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
$newConfigHash = base64_encode($this->fqdn.json_encode($this->noindexDomains()->all()).$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort());
} else {
@@ -194,6 +194,7 @@ class ApplicationConfigurationSnapshot
{
return [
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
$this->item('noindex_domains', 'Search engine indexing', $this->application->noindexDomains()->all(), 'redeploy'),
$this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
@@ -22,7 +22,7 @@ class ConfigurationDiffer
* stored. Older snapshots omitted these keys, which should not make an
* unchanged default look like a pending configuration change.
*
* @var array<string, bool|array<int, bool>>
* @var array<string, mixed>
*/
private const INTRODUCED_DEFAULTS = [
'build.is_static' => false,
@@ -37,6 +37,7 @@ class ConfigurationDiffer
'runtime.is_log_drain_enabled' => false,
'runtime.is_swarm_only_worker_nodes' => true,
'runtime.is_preserve_repository_enabled' => false,
'domains.noindex_domains' => [],
];
/**
@@ -61,7 +62,7 @@ class ConfigurationDiffer
if (
$previous === null
&& array_key_exists($key, self::INTRODUCED_DEFAULTS)
&& in_array((bool) data_get($current, 'compare_value'), (array) self::INTRODUCED_DEFAULTS[$key], true)
&& $this->matchesIntroducedDefault($key, data_get($current, 'compare_value'))
) {
continue;
}
@@ -126,6 +127,17 @@ class ConfigurationDiffer
return ConfigurationDiff::fromChanges($changes);
}
private function matchesIntroducedDefault(string $key, mixed $value): bool
{
$default = self::INTRODUCED_DEFAULTS[$key];
if (is_array($default) && $default !== [] && array_is_list($default)) {
return in_array($value, $default, true);
}
return $value === $default;
}
/**
* Reduce two multi-line values to only the lines that differ, so the modal
* shows just the changed container labels instead of the whole block.
@@ -9,6 +9,7 @@
'emptyText' => 'No options available.',
'live' => false,
'onChange' => null, // optional $wire method to call after a selection
'onChangeArgs' => null, // optional arguments followed by the selected value
'wire' => true, // false = purely client-side value (no Livewire binding)
'value' => null, // initial value when wire=false
'disabled' => false,
@@ -55,7 +56,12 @@
this.open = false;
if (String(option.value) === String(this.value)) return;
this.value = option.value;
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif
this.$dispatch('listbox-change', { value: option.value });
@if ($onChange && is_array($onChangeArgs))
this.$nextTick(() => this.$wire.{{ $onChange }}(...@js($onChangeArgs), option.value));
@elseif ($onChange)
this.$nextTick(() => this.$wire.{{ $onChange }}());
@endif
},
toggle() {
this.open = !this.open;
@@ -102,8 +108,15 @@
</button>
@if ($portal)
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel" style="position: fixed; z-index: 9999" x-show="open"
<div id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open && positioned"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
@@ -124,7 +137,13 @@
</div>
</template>
@else
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak role="listbox">
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
@@ -15,15 +15,16 @@
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''),
// Local-only until Save — never touch $wire on open/close (avoids Livewire toJSON proxy bugs).
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingService: @js($editingService),
openEditDomain(index, url, service) {
localIndexing: 'index',
openEditDomain(index, url, service, indexing) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingService = service;
this.editingServiceLabel = service || '';
this.localIndexing = indexing || 'index';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
@@ -39,6 +40,7 @@
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingService = this.localEditingService;
$wire.editingIndexing = this.localIndexing;
$wire.showEditDomainModal = true;
},
matchesDomainSearch(value) {
@@ -48,7 +50,7 @@
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service, $event.detail.indexing)"
@edit-domain-saved.window="closeEditDomain()">
<x-application.settings-section id="domains-section" title="Domains" :helper="$helperText">
@can('update', $application)
@@ -81,16 +83,11 @@
@if (! $isCompose)
@if ($labelsAreWritable)
@if ($application->redirect === 'both')
<x-forms.input label="Direction" value="Allow www & non-www" readonly
helper="Readonly labels are disabled. You can set the direction in the labels section." />
@elseif ($application->redirect === 'www')
<x-forms.input label="Direction" value="Redirect to www" readonly
helper="Readonly labels are disabled. You can set the direction in the labels section." />
@elseif ($application->redirect === 'non-www')
<x-forms.input label="Direction" value="Redirect to non-www" readonly
helper="Readonly labels are disabled. You can set the direction in the labels section." />
@endif
<x-forms.input label="Direction" value="{{ match ($application->redirect) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow www & non-www',
} }}" readonly helper="Readonly labels are disabled. You can set the direction in the labels section." />
@else
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
@@ -115,9 +112,10 @@
@endif
@elseif (! $labelsAreWritable && count($composeServices) > 0 && $composeDomainGroups->isNotEmpty())
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Per-service www/non-www redirects are available next to each service group in the table below.
Per-service www/non-www redirects are available next to each service group below.
</p>
@endif
</x-application.settings-section>
{{-- Toolbar --}}
@@ -140,7 +138,9 @@
</div>
@endif
@can('update', $application)
@include('livewire.project.shared.cloudflare-autoconfigure')
<div class="relative shrink-0">
@include('livewire.project.shared.cloudflare-autoconfigure')
</div>
@unless ($labelsAreWritable)
@if (! $isCompose || count($composeServices) > 0)
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
@@ -236,8 +236,7 @@
@php
$rows = $grouped->get($serviceName, collect());
$redirectWireKey = $this->serviceRedirectWireKey($serviceName);
$redirect = $serviceRedirects[$redirectWireKey] ?? 'both';
$redirectLabel = match ($redirect) {
$redirectLabel = match ($serviceRedirects[$redirectWireKey] ?? 'both') {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
@@ -253,26 +252,14 @@
</span>
@unless ($labelsAreWritable)
@can('update', $application)
<div class="relative flex shrink-0 items-center gap-2 px-1 py-1 text-sm text-neutral-600 dark:text-fg-dim"
wire:loading.class="opacity-50"
wire:target="serviceRedirects.{{ $redirectWireKey }}">
<span>{{ $redirectLabel }}</span>
<x-reicon name="chevron-down" class="size-4 shrink-0"
wire:loading.remove
wire:target="serviceRedirects.{{ $redirectWireKey }}" />
<x-loading-on-button wire:loading.delay
wire:target="serviceRedirects.{{ $redirectWireKey }}" />
<select id="application-compose-domain-redirect-{{ $redirectWireKey }}"
wire:model.change="serviceRedirects.{{ $redirectWireKey }}"
wire:change="setServiceRedirect(@js($serviceName))"
wire:loading.attr="disabled"
wire:target="serviceRedirects.{{ $redirectWireKey }}"
class="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-wait"
aria-label="Redirect direction for {{ $serviceName }}">
<option value="both">Allow www & non-www</option>
<option value="www">Redirect to www</option>
<option value="non-www">Redirect to non-www</option>
</select>
<div class="w-52 shrink-0">
<x-forms.listbox id="serviceRedirects.{{ $redirectWireKey }}"
htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"
live :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" wire:change="setServiceRedirect(@js($serviceName))" />
</div>
@else
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
@@ -384,6 +371,14 @@
@enderror
</div>
@unless ($labelsAreWritable)
<x-forms.listbox id="edit-domain-indexing" label="Search engine indexing"
:wire="false" value="index" x-model="localIndexing" portal :options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
@endunless
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
@@ -71,25 +71,6 @@
<x-reicon name="settings" class="size-4" />
</a>
</div>
@if ($buildPack !== 'dockercompose' && $application->settings->is_container_label_readonly_enabled && count($this->configuredDomains) > 0)
<div class="mt-4 flex flex-col gap-2">
<div class="flex items-center gap-2">
<h4>Search engine indexing</h4>
<x-helper
helper="Checked domains are served with an <span class='text-helper'>X-Robots-Tag: noindex, nofollow</span> response header, which keeps them out of search engines.<br><br>Useful for auto-generated or staging domains you do not want indexed, while your production domain stays indexable.<br><br>This header overrides any X-Robots-Tag your application sets itself.<br><br>Preview deployments are always noindex." />
</div>
@foreach ($this->configuredDomains as $domain)
<label
class="form-control flex max-w-full cursor-pointer flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100">
<span class="flex min-w-0 grow gap-2 break-words">{{ $domain }}</span>
<input type="checkbox" value="{{ $domain }}" wire:model="noindexDomains"
wire:change="updateNoindexDomains" wire:loading.attr="disabled"
x-bind:disabled="!canUpdate"
class="shrink-0 cursor-pointer rounded-sm text-coolgray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:border-neutral-700 dark:bg-coolgray-100 dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base dark:disabled:cursor-not-allowed dark:disabled:bg-base" />
</label>
@endforeach
</div>
@endif
</section>
@if ($buildPack !== 'dockercompose')
@@ -107,6 +107,7 @@
index: {{ $index }},
url: @js($row['url']),
service: @js($row['service'] ?? null),
indexing: @js($application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'),
})"
class="icon-button shrink-0"
title="Edit domain" aria-label="Edit domain">
@@ -22,11 +22,15 @@
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingServiceApplicationId: @js($editingServiceApplicationId),
openEditDomain(index, url, serviceApplicationId, serviceLabel) {
localDirection: 'both',
localIndexing: 'index',
openEditDomain(index, url, serviceApplicationId, serviceLabel, direction, indexing) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingServiceApplicationId = serviceApplicationId;
this.editingServiceLabel = serviceLabel || '';
this.localDirection = direction || 'both';
this.localIndexing = indexing || 'index';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
@@ -41,6 +45,8 @@
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingServiceApplicationId = this.localEditingServiceApplicationId;
$wire.editingDirection = this.localDirection;
$wire.editingIndexing = this.localIndexing;
$wire.showEditDomainModal = true;
},
matchesDomainSearch(value) {
@@ -50,7 +56,7 @@
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel, $event.detail.direction, $event.detail.indexing)"
@edit-domain-saved.window="closeEditDomain()">
<x-application.settings-section id="service-domains-section" title="Domains">
@can('update', $service)
@@ -94,7 +100,9 @@
@endif
@can('update', $service)
@if ($serviceAppCount > 0)
@include('livewire.project.shared.cloudflare-autoconfigure')
<div class="relative shrink-0">
@include('livewire.project.shared.cloudflare-autoconfigure')
</div>
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
canGate="update" :canResource="$service">
<x-slot:content>
@@ -177,31 +185,12 @@
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
$heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service');
$redirect = $serviceRedirects[$appId] ?? 'both';
$redirectLabel = match ($redirect) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
};
@endphp
<section id="service-domain-group-{{ $appId }}" wire:key="service-domain-group-{{ $appId }}"
x-show="matchesDomainSearch(@js($heading.' '.$rows->pluck('url')->implode(' ')))"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
<div class="flex w-full items-center gap-3 px-4 py-3">
<div class="flex w-full items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
@can('update', $service)
<div class="w-52 shrink-0"
wire:loading.class="opacity-50" wire:target="serviceRedirects.{{ $appId }}">
<x-forms.listbox id="serviceRedirects.{{ $appId }}"
htmlId="service-domain-redirect-{{ $appId }}" live :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
@else
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
@endcan
</div>
<div wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}">
@@ -210,7 +199,7 @@
'domainRows' => $domainRows,
'service' => $service,
'showServiceColumn' => false,
'showHeader' => false,
'showHeader' => true,
])
</div>
</section>
@@ -279,6 +268,20 @@
@enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="edit-service-domain-direction" label="Direction"
:wire="false" value="both" x-model="localDirection" portal :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
<x-forms.listbox id="edit-service-domain-indexing" label="Search engine indexing"
:wire="false" value="index" x-model="localIndexing" portal :options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
</div>
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
@@ -16,26 +16,6 @@
</div>
</form>
@if (count($this->configuredDomains) > 0)
<div class="flex flex-col gap-2 pt-4">
<div class="flex items-center gap-2">
<h4>Search Engine Indexing</h4>
<x-helper
helper="Checked domains are served with an <span class='text-helper'>X-Robots-Tag: noindex, nofollow</span> response header, which keeps them out of search engines.<br><br>Useful for auto-generated or staging domains you do not want indexed, while your production domain stays indexable.<br><br>This header overrides any X-Robots-Tag your service sets itself." />
</div>
@foreach ($this->configuredDomains as $domain)
<label
class="form-control flex max-w-full cursor-pointer flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100">
<span class="flex min-w-0 grow gap-2 break-words">{{ $domain }}</span>
<input type="checkbox" value="{{ $domain }}" wire:model="noindexDomains"
wire:change="updateNoindexDomains" wire:loading.attr="disabled"
@cannot('update', $application) disabled @endcannot
class="shrink-0 cursor-pointer rounded-sm text-coolgray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:border-neutral-700 dark:bg-coolgray-100 dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base dark:disabled:cursor-not-allowed dark:disabled:bg-base" />
</label>
@endforeach
</div>
@endif
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" confirmAction="confirmDomainUsage">
<x-slot:consequences>
<ul class="mt-2 ml-4 list-disc">
@@ -131,9 +131,11 @@
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
serviceLabel: @js($serviceLabel),
})"
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
serviceLabel: @js($serviceLabel),
direction: @js($serviceRedirects[$row['service_application_id']] ?? 'both'),
indexing: @js($service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'noindex' : 'index'),
})"
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" />
</button>
@@ -8,9 +8,10 @@
x-bind:aria-expanded="dnsEntriesOpen" title="DNS entries for this server">
<x-reicon name="globe" class="size-3.5" />
DNS entries
<span class="inline-flex transition-transform" :class="dnsEntriesOpen && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</button>
<div x-show="dnsEntriesOpen" x-cloak role="menu" x-transition.origin.top.right
class="listbox-panel left-auto! right-0! z-[90]! w-56! min-w-56!">
@@ -58,6 +58,42 @@ it('stores a diff between successful deployments', function () {
->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
});
it('reports noindex domain changes as requiring a redeploy', function () {
$application = configurationChangedTestApplication([
'fqdn' => 'https://app.example.com,https://staging.example.com',
]);
$deployment = configurationChangedDeployment($application);
$application->markDeploymentConfigurationApplied($deployment);
$application->setNoindexDomains(['https://staging.example.com']);
$application->save();
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('key', 'domains.noindex_domains');
expect($diff->isChanged())->toBeTrue()
->and($change)->not->toBeNull()
->and($change['label'])->toBe('Search engine indexing')
->and($change['impact'])->toBe('redeploy');
});
it('does not flag applications whose older snapshot omitted noindex domains', function () {
$application = configurationChangedTestApplication([
'fqdn' => 'https://app.example.com',
]);
$deployment = configurationChangedDeployment($application);
$application->markDeploymentConfigurationApplied($deployment);
$snapshot = $deployment->refresh()->configuration_snapshot;
$snapshot['sections']['domains']['items'] = collect($snapshot['sections']['domains']['items'])
->reject(fn (array $item): bool => $item['key'] === 'noindex_domains')
->values()
->all();
$deployment->update(['configuration_snapshot' => $snapshot]);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
});
it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
$application = configurationChangedTestApplication();
+25 -4
View File
@@ -271,6 +271,8 @@ it('updates a domain in place via modal', function () {
->call('startEdit', 0)
->assertSet('showEditDomainModal', true)
->assertSet('editingDomain', 'https://old.example.com')
->assertSee('Direction')
->assertSee('Search engine indexing')
->set('editingDomain', 'https://new.example.com')
->call('updateDomain')
->assertHasNoErrors()
@@ -375,7 +377,8 @@ it('auto-adds missing www counterpart as a normal domain when setting www redire
->assertSet('domainRows.0.is_suggested', false)
->assertSet('domainRows.1.is_suggested', false)
->assertSet('domainRows.0.url', 'https://example.com')
->assertSet('domainRows.1.url', 'https://www.example.com');
->assertSet('domainRows.1.url', 'https://www.example.com')
->assertSet('domainRows.1.checked_at', fn (?string $checkedAt): bool => filled($checkedAt));
$this->application->refresh();
@@ -1216,9 +1219,8 @@ it('uses the compact service domains layout for compose applications', function
expect($view)
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }}"')
->not->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }},setServiceRedirect"')
->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"')
->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
->not->toContain('title="No domains for this service"');
});
@@ -1227,6 +1229,8 @@ it('provides client-side search for compose service domains', function () {
expect($view)
->toContain('x-model="domainSearch"')
->toContain('class="ml-auto flex flex-wrap items-center gap-2"')
->toContain('<div class="relative shrink-0">')
->toContain('placeholder="Search services or domains"')
->toContain('x-show="matchesDomainSearch(')
->toContain('title="No domains found"')
@@ -1326,3 +1330,20 @@ it('uses compose service redirect for suggested domain messaging when direction
->and($suggested['suggestion_role'] ?? null)->toBe('pair')
->and($suggested['url'] ?? null)->toBe('https://www.web.example.com');
});
it('updates search engine indexing from the domains view', function () {
$this->application->update(['fqdn' => 'https://app.example.com,https://staging.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Noindex')
->assertSee('Indexable')
->assertSee('x-model="localIndexing"', false)
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
->assertDontSee('@js(', false)
->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex')
->assertDispatched('configurationChanged')
->assertDispatched('success');
expect($this->application->refresh()->noindexDomains()->all())
->toBe(['https://staging.example.com']);
});
@@ -1,6 +1,6 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\Application\Domains;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -84,7 +84,7 @@ describe('Application noindex domains', function () {
expect($application->isDomainNoindexed('https://prod.example.com'))->toBeFalse();
});
test('the Livewire toggle persists the flag', function () {
test('the domains view toggle persists the flag', function () {
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
@@ -107,10 +107,9 @@ describe('Application noindex domains', function () {
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(Domains::class, ['application' => $application])
->assertSuccessful()
->set('noindexDomains', ['https://staging.example.com'])
->call('updateNoindexDomains')
->call('toggleNoindexDomain', 'https://staging.example.com', true)
->assertDispatched('success');
expect($application->refresh()->noindexDomains()->all())
@@ -159,6 +159,26 @@ it('shows domain changes when the domain page dispatches a configuration change'
->assertSee('https://changed.example.com');
});
it('shows noindex changes when the domains page dispatches a configuration change', function () {
$application = configurationCheckerApplication($this->environment, [
'fqdn' => 'https://example.com,https://staging.example.com',
]);
markConfigurationCheckerApplicationDeployed($application);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSet('isConfigurationChanged', false);
$application->setNoindexDomains(['https://staging.example.com']);
$application->save();
$component
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->assertSee('Search engine indexing')
->assertSee('Redeploy to apply.');
});
it('shows an unapplied configuration warning after a directory mount is added', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
@@ -1,5 +1,6 @@
<?php
use App\Livewire\Project\Service\Domains;
use App\Livewire\Project\Service\EditDomain;
use App\Models\Environment;
use App\Models\Project;
@@ -77,9 +78,8 @@ it('loads the EditDomain component with required port', function () {
it('marks noindex changes as pending configuration', function () {
$this->service->isConfigurationChanged(save: true);
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('noindexDomains', ['http://example.com:8000'])
->call('updateNoindexDomains')
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('toggleNoindexDomain', $this->serviceApplication->id, 'http://example.com:8000', true)
->assertDispatched('configurationChanged');
expect($this->service->refresh()->isConfigurationChanged())->toBeTrue();
+41 -4
View File
@@ -90,7 +90,7 @@ beforeEach(function () {
]);
});
it('groups configured domains with their service redirect and excludes services without domains', function () {
it('groups configured domains and shows redirect settings in the edit modal', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com,https://admin.example.com',
]);
@@ -100,18 +100,22 @@ it('groups configured domains with their service redirect and excludes services
->assertSee('API')
->assertSee('https://api.example.com')
->assertSee('https://admin.example.com')
->call('startEdit', 0)
->html();
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
->toContain("id=\"service-domain-redirect-{$this->apiApp->id}-trigger\"")
->toContain("id=\"edit-service-domain-redirect-{$this->apiApp->id}-trigger\"")
->toContain("serviceRedirects.{$this->apiApp->id}")
->toContain('class="listbox-trigger"')
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
->not->toContain("<select id=\"service-domain-redirect-{$this->apiApp->id}\"")
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
->toContain('dark:bg-white/[0.04]')
->toContain('<span>Domain</span>')
->toContain('<span>DNS</span>')
->toContain('<span>Last checked</span>')
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
->and(strpos($html, '>API</span>'))->toBeLessThan(strpos($html, '<span>Domain</span>'))
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
});
@@ -194,6 +198,7 @@ it('saves the explicitly selected service redirect value', function () {
->call('updateServiceRedirect', $this->webApp->id, 'www')
->assertDispatched('success')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
->assertSet('domainRows', fn (array $rows): bool => filled(collect($rows)->firstWhere('url', 'https://www.web.example.com')['checked_at'] ?? null))
->assertSee('https://www.web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('www');
@@ -270,6 +275,8 @@ it('provides client-side search for service domains', function () {
expect($view)
->toContain('x-model="domainSearch"')
->toContain('class="ml-auto flex flex-wrap items-center gap-2"')
->toContain('<div class="relative shrink-0">')
->toContain('placeholder="Search services or domains"')
->toContain('x-show="matchesDomainSearch(')
->toContain('title="No domains found"')
@@ -343,6 +350,8 @@ it('prunes the previous dns status when a service domain is renamed', function (
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->assertSee('Direction')
->assertSee('Search engine indexing')
->set('editingDomain', 'https://renamed.example.com')
->call('updateDomain')
->assertHasNoErrors()
@@ -357,6 +366,15 @@ it('prunes the previous dns status when a service domain is renamed', function (
->and($this->apiApp->domain_dns_statuses['https://renamed.example.com']['status'])->toBe('skipped');
});
it('only shows the domain notification when redirect changes through edit domain', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->set('editingDirection', 'www')
->call('updateDomain')
->assertDispatched('success', 'Domain updated.')
->assertNotDispatched('success', 'Redirect updated.');
});
it('does not restore stale dns status when a removed service domain is re-added', function () {
$this->apiApp->update([
'domain_dns_statuses' => [
@@ -509,3 +527,22 @@ it('exposes the stack domains route', function () {
->assertSeeLivewire(Domains::class)
->assertSee('Domains');
});
it('updates search engine indexing from the service domains view', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('Noindex')
->assertSee('Indexable')
->assertSee('x-model="localIndexing"', false)
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
->assertDontSee('@change="$wire.updateServiceRedirect', false)
->assertDontSee('@js(', false)
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
->assertDispatched('configurationChanged')
->assertDispatched('success');
expect($this->apiApp->refresh()->noindexDomains()->all())
->toBe(['https://api.example.com']);
expect(file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')))
->not->toContain('<select');
});