feat(ui): polish email modal, search loading, and volume tables

Move profile email change into a modal with Alpine focus/close handling,
show a loading overlay during command-palette navigation, and adjust
volumes/config-diff grids plus related service/storage UI. Cover with
feature tests for search, profile, storage, and domains.
This commit is contained in:
Andras Bacsai
2026-08-05 14:07:19 +02:00
parent a7a06aa6c8
commit 0312cc8ffb
18 changed files with 236 additions and 109 deletions
+1 -6
View File
@@ -167,6 +167,7 @@ class Index extends Component
$this->show_verification = false; $this->show_verification = false;
$this->dispatch('success', 'Email address updated successfully.'); $this->dispatch('success', 'Email address updated successfully.');
$this->dispatch('close-email-change-modal');
} else { } else {
$this->dispatch('error', 'Failed to update email address.'); $this->dispatch('error', 'Failed to update email address.');
} }
@@ -231,12 +232,6 @@ class Index extends Component
$this->dispatch('success', 'Email change request cancelled.'); $this->dispatch('success', 'Email change request cancelled.');
} }
public function showEmailChangeForm()
{
$this->show_email_change = true;
$this->new_email = '';
}
public function resetPassword() public function resetPassword()
{ {
try { try {
+12 -7
View File
@@ -1881,17 +1881,17 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem; grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem;
} }
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | [Actions] */ /* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
.volumes-table-grid-readonly { .volumes-table-grid-readonly {
grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr); grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 5rem;
} }
.volumes-table-grid { .volumes-table-grid {
grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(10.5rem, auto); grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) 5rem 17.5rem;
} }
.volumes-table-grid-with-pr { .volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem minmax(10.5rem, auto); grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem 5rem 17.5rem;
} }
.volumes-mobile-label { .volumes-mobile-label {
@@ -1927,7 +1927,7 @@ input[type="search"]::-webkit-search-results-decoration {
@media (max-width: 1100px) { @media (max-width: 1100px) {
.volumes-table-grid { .volumes-table-grid {
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) minmax(9rem, auto); grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 17.5rem;
} }
.volumes-table-grid > .volumes-col-source, .volumes-table-grid > .volumes-col-source,
@@ -1936,7 +1936,7 @@ input[type="search"]::-webkit-search-results-decoration {
} }
.volumes-table-grid-with-pr { .volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem minmax(9rem, auto); grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 17.5rem;
} }
.volumes-table-grid-with-pr > .volumes-col-source, .volumes-table-grid-with-pr > .volumes-col-source,
@@ -1945,7 +1945,7 @@ input[type="search"]::-webkit-search-results-decoration {
} }
.volumes-table-grid-readonly { .volumes-table-grid-readonly {
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr); grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem;
} }
.volumes-table-grid-readonly > .volumes-col-source, .volumes-table-grid-readonly > .volumes-col-source,
@@ -2001,6 +2001,7 @@ input[type="search"]::-webkit-search-results-decoration {
} }
.volumes-cell-name, .volumes-cell-name,
.volumes-col-backup,
.volumes-cell-dest, .volumes-cell-dest,
.volumes-cell-actions { .volumes-cell-actions {
display: flex; display: flex;
@@ -2010,6 +2011,10 @@ input[type="search"]::-webkit-search-results-decoration {
width: 100%; width: 100%;
} }
.volumes-col-backup {
align-items: flex-start;
}
.volumes-cell-actions { .volumes-cell-actions {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
@@ -19,7 +19,7 @@
<div class="overflow-x-auto rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]"> <div class="overflow-x-auto rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="min-w-[640px]"> <div class="min-w-[640px]">
<div <div
class="grid grid-cols-[minmax(12rem,1.5fr)_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)_1.75rem] items-center gap-3 bg-neutral-100 px-3 py-2 text-[11px] font-medium text-neutral-500 dark:bg-white/[0.04] dark:text-fg-faint"> class="grid grid-cols-[minmax(10rem,0.8fr)_minmax(14rem,1fr)_1.5rem_minmax(14rem,1fr)_1.75rem] items-center gap-3 bg-neutral-100 px-3 py-2 text-[11px] font-medium text-neutral-500 dark:bg-white/[0.04] dark:text-fg-faint">
<div>Field</div> <div>Field</div>
<div>Current</div> <div>Current</div>
<div></div> <div></div>
@@ -42,7 +42,7 @@
@endphp @endphp
<div <div
class="grid grid-cols-[minmax(12rem,1.5fr)_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)_1.75rem] items-start gap-3 px-3 py-2.5 text-sm transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]"> class="grid grid-cols-[minmax(10rem,0.8fr)_minmax(14rem,1fr)_1.5rem_minmax(14rem,1fr)_1.75rem] items-start gap-3 px-3 py-2.5 text-sm transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]">
<div class="min-w-0 font-medium text-neutral-900 dark:text-fg"> <div class="min-w-0 font-medium text-neutral-900 dark:text-fg">
@if ($rowExpandable) @if ($rowExpandable)
<div class="break-words" <div class="break-words"
@@ -3,6 +3,7 @@
selectedIndex: -1, selectedIndex: -1,
isSearching: false, isSearching: false,
isLoadingInitialData: false, isLoadingInitialData: false,
isPaletteTransitioning: false,
allSearchableItems: [], allSearchableItems: [],
searchQuery: '', searchQuery: '',
creatableItems: [], creatableItems: [],
@@ -97,6 +98,7 @@
this.isLoadingInitialData = false; this.isLoadingInitialData = false;
this.searchQuery = ''; this.searchQuery = '';
this.allSearchableItems = []; this.allSearchableItems = [];
this.isPaletteTransitioning = false;
// Ensure scroll is restored // Ensure scroll is restored
document.body.style.overflow = ''; document.body.style.overflow = '';
// Use $wire instead of @this for SPA navigation compatibility // Use $wire instead of @this for SPA navigation compatibility
@@ -104,6 +106,12 @@
$wire.closeSearchModal(); $wire.closeSearchModal();
} }
}, },
runPaletteTransition(callback) {
this.isPaletteTransitioning = true;
return Promise.resolve(callback()).finally(() => {
this.isPaletteTransitioning = false;
});
},
navigateResults(direction) { navigateResults(direction) {
const results = document.querySelectorAll('.search-result-item'); const results = document.querySelectorAll('.search-result-item');
if (results.length === 0) return; if (results.length === 0) return;
@@ -162,7 +170,7 @@
}); });
if (matchingItem && typeof $wire !== 'undefined' && $wire) { if (matchingItem && typeof $wire !== 'undefined' && $wire) {
$wire.navigateToResource(matchingItem.type); this.runPaletteTransition(() => $wire.navigateToResource(matchingItem.type));
} }
} }
}); });
@@ -313,14 +321,21 @@
</div> </div>
<!-- Search results --> <!-- Search results -->
<div x-show="searchQuery.length >= 1" x-cloak class="command-palette-body"> <div x-show="searchQuery.length >= 1" x-cloak class="command-palette-body relative">
<div x-show="isPaletteTransitioning" x-cloak
class="absolute inset-0 z-30 flex items-center justify-center bg-white/50 backdrop-blur-[2px] dark:bg-black/40">
<x-loading text="Loading…" />
</div>
@if ($isSelectingResource) @if ($isSelectingResource)
<!-- Resource selection flow --> <!-- Resource selection flow -->
<div class="command-palette-section"> <div class="relative min-h-32">
<div class="command-palette-section transition-all"
wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
wire:target="selectServer,selectDestination,selectProject,selectEnvironment">
@if ($selectedServerId === null) @if ($selectedServerId === null)
<div x-init="selectedIndex = -1"> <div x-init="selectedIndex = -1">
<div class="command-palette-step-header"> <div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back" <button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back"> title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" /> <x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button> </button>
@@ -367,7 +382,7 @@
@if ($selectedServerId !== null && $selectedDestinationUuid === null) @if ($selectedServerId !== null && $selectedDestinationUuid === null)
<div x-init="selectedIndex = -1"> <div x-init="selectedIndex = -1">
<div class="command-palette-step-header"> <div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back" <button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back"> title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" /> <x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button> </button>
@@ -415,7 +430,7 @@
@if ($selectedDestinationUuid !== null && $selectedProjectUuid === null) @if ($selectedDestinationUuid !== null && $selectedProjectUuid === null)
<div x-init="selectedIndex = -1"> <div x-init="selectedIndex = -1">
<div class="command-palette-step-header"> <div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back" <button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back"> title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" /> <x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button> </button>
@@ -463,7 +478,7 @@
@if ($selectedProjectUuid !== null && $selectedEnvironmentUuid === null) @if ($selectedProjectUuid !== null && $selectedEnvironmentUuid === null)
<div x-init="selectedIndex = -1"> <div x-init="selectedIndex = -1">
<div class="command-palette-step-header"> <div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back" <button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back"> title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" /> <x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button> </button>
@@ -509,6 +524,12 @@
@endif @endif
</div> </div>
@endif @endif
</div>
<div wire:loading.flex
wire:target="selectServer,selectDestination,selectProject,selectEnvironment"
class="absolute inset-0 z-10 hidden items-center justify-center bg-white/40 backdrop-blur-[1px] dark:bg-black/30">
<x-loading text="Loading selection…" />
</div>
</div> </div>
@elseif ($isCreateMode && count($this->filteredCreatableItems) > 0 && !$autoOpenResource) @elseif ($isCreateMode && count($this->filteredCreatableItems) > 0 && !$autoOpenResource)
<!-- Create mode (server-rendered path) --> <!-- Create mode (server-rendered path) -->
@@ -569,7 +590,7 @@
<div class="command-palette-section"> <div class="command-palette-section">
<div class="command-palette-group-label">{{ $category }}</div> <div class="command-palette-group-label">{{ $category }}</div>
@foreach ($items as $item) @foreach ($items as $item)
<button type="button" wire:click="navigateToResource('{{ $item['type'] }}')" <button type="button" @click="runPaletteTransition(() => $wire.navigateToResource('{{ $item['type'] }}'))"
class="search-result-item command-palette-item"> class="search-result-item command-palette-item">
@if (!empty($item['logo'])) @if (!empty($item['logo']))
<div class="command-palette-item-icon"> <div class="command-palette-item-icon">
@@ -646,7 +667,7 @@
<div class="command-palette-section"> <div class="command-palette-section">
<div class="command-palette-group-label" x-text="categoryName"></div> <div class="command-palette-group-label" x-text="categoryName"></div>
<template x-for="item in items" :key="item.type"> <template x-for="item in items" :key="item.type">
<button type="button" @click="$wire.navigateToResource(item.type)" <button type="button" @click="runPaletteTransition(() => $wire.navigateToResource(item.type))"
class="search-result-item command-palette-item"> class="search-result-item command-palette-item">
<template x-if="item.logo"> <template x-if="item.logo">
<div class="command-palette-item-icon"> <div class="command-palette-item-icon">
@@ -1,4 +1,11 @@
<div> <div x-data="{
emailModalOpen: @js($show_verification),
openEmailModal() {
this.emailModalOpen = true;
this.$nextTick(() => this.$refs.newEmailInput?.focus());
},
}"
@close-email-change-modal.window="emailModalOpen = false">
<x-slot:title>Profile | Coolify</x-slot> <x-slot:title>Profile | Coolify</x-slot>
<x-profile.navbar /> <x-profile.navbar />
@@ -16,8 +23,8 @@
<x-forms.input id="name" label="Name" required /> <x-forms.input id="name" label="Name" required />
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<x-forms.input id="email" label="Email" readonly /> <x-forms.input id="email" label="Email" readonly />
<x-forms.button wire:click="showEmailChangeForm" type="button" <x-forms.button @click="openEmailModal()" type="button"
:disabled="$show_email_change || $show_verification"> x-bind:disabled="emailModalOpen">
Change Change
</x-forms.button> </x-forms.button>
</div> </div>
@@ -25,53 +32,56 @@
</section> </section>
</form> </form>
@if ($show_email_change) <template x-teleport="body">
<form wire:submit="requestEmailChange"> <div x-show="emailModalOpen" x-cloak
<section class="application-settings-section"> class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4">
<div class="application-settings-section-header"> <div class="absolute inset-0 h-full w-full bg-black/55 backdrop-blur-[3px]"></div>
<div x-show="emailModalOpen" x-trap.inert.noscroll="emailModalOpen"
class="application-settings-form application-settings-section relative w-full max-w-xl overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<div> <div>
<h2>Change email</h2> <h3>{{ $show_verification ? 'Verify new email' : 'Change email' }}</h3>
<p>A six-digit verification code will be sent to the new address.</p> <p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
</div> @if ($show_verification)
</div> Code sent to {{ $new_email ?: auth()->user()->pending_email }}.
<div class="application-settings-section-body"> @else
<div class="flex max-w-xl items-end gap-2"> A six-digit verification code will be sent to the new address.
<x-forms.input id="new_email" label="New email address" required type="email" /> @endif
<x-forms.button type="submit">Send code</x-forms.button>
<x-forms.button wire:click="$set('show_email_change', false)" type="button">
Cancel
</x-forms.button>
</div>
</div>
</section>
</form>
@endif
@if ($show_verification)
<form wire:submit="verifyEmailChange">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Verify new email</h2>
<p>
Code sent to {{ $new_email ?? auth()->user()->pending_email }}.
It expires after {{ config('constants.email_change.verification_code_expiry_minutes', 10) }}
minutes.
</p> </p>
</div> </div>
</div> <button type="button"
<div class="application-settings-section-body"> @click="@if ($show_verification) $wire.cancelEmailChange().then(() => emailModalOpen = false) @else emailModalOpen = false @endif"
<div class="flex max-w-xl items-end gap-2"> class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
@if ($show_verification)
<form wire:submit="verifyEmailChange" class="application-settings-section-body space-y-4">
<x-forms.input id="email_verification_code" label="Verification code" required <x-forms.input id="email_verification_code" label="Verification code" required
inputmode="numeric" maxlength="6" /> inputmode="numeric" maxlength="6" />
<x-forms.button type="submit">Verify email</x-forms.button> <p class="text-xs text-neutral-500 dark:text-fg-dim">
<x-forms.button wire:click="resendVerificationCode" type="button">Resend</x-forms.button> The code expires after
<x-forms.button wire:click="cancelEmailChange" type="button">Cancel</x-forms.button> {{ config('constants.email_change.verification_code_expiry_minutes', 10) }} minutes.
</div> </p>
</div> <div class="flex justify-end gap-2">
</section> <x-forms.button wire:click="resendVerificationCode" type="button">Resend code</x-forms.button>
</form> <x-forms.button type="submit" isHighlighted>Verify email</x-forms.button>
@endif </div>
</form>
@else
<form wire:submit="requestEmailChange" class="application-settings-section-body space-y-4">
<x-forms.input id="new_email" label="New email address" required type="email"
x-ref="newEmailInput" />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted>Send code</x-forms.button>
</div>
</form>
@endif
</div>
</div>
</template>
<form wire:submit="resetPassword"> <form wire:submit="resetPassword">
<section class="application-settings-section"> <section class="application-settings-section">
@@ -202,7 +202,7 @@
{{ $backup->targetName() }} {{ $backup->targetName() }}
</span> </span>
<span>{{ $backup->targetType() }}</span> <span>{{ $backup->targetType() }}</span>
<span class="font-mono text-xs">{{ $backup->frequency }}</span> <span>{{ $backup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span> <span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span> <span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
@@ -38,6 +38,13 @@
$groupedItems = collect($menuGroups) $groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values()) ->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty()); ->filter(fn ($items) => $items->isNotEmpty());
$storageSections = $applications
->concat($databases)
->map(fn ($resource): array => [
'id' => 'storage-service-'.$resource->uuid,
'label' => Str::headline($resource->name),
]);
@endphp @endphp
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0"> <section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
@@ -61,6 +68,19 @@
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" /> <x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span> <span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a> </a>
@if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{ activeSection: '' }">
@foreach ($storageSections as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="activeSection = '{{ $section['id'] }}'; window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label truncate text-left"
title="{{ $section['label'] }}">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach @endforeach
@endforeach @endforeach
</nav> </nav>
@@ -112,6 +132,10 @@
<livewire:project.shared.environment-variable.all :resource="$service" /> <livewire:project.shared.environment-variable.all :resource="$service" />
@elseif ($currentRoute === 'project.service.storages') @elseif ($currentRoute === 'project.service.storages')
<div class="space-y-6"> <div class="space-y-6">
<div
class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] leading-5 text-amber-800 dark:border-warning/15 dark:bg-warning/[0.07] dark:text-amber-300/90">
Service volume mounts are read-only here. Edit the Docker Compose file and reload it to change volumes.
</div>
@foreach ($applications as $application) @foreach ($applications as $application)
<livewire:project.service.storage wire:key="application-{{ $application->id }}" <livewire:project.service.storage wire:key="application-{{ $application->id }}"
:resource="$application" /> :resource="$application" />
@@ -67,9 +67,6 @@
@if ($isSuggested && ! empty($row['suggestion_label'])) @if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span> <span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@endif @endif
@if ($showServiceColumn)
<span class="domains-service-mobile table-badge shrink-0">{{ $serviceLabel }}</span>
@endif
</div> </div>
@if ($isSuggested && filled($row['dns_message'])) @if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90" <p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
@@ -416,7 +416,7 @@
</x-application.settings-section> </x-application.settings-section>
@else @else
{{-- Service stack resources: one settings card + table per service --}} {{-- Service stack resources: one settings card + table per service --}}
<x-application.settings-section :id="'storage-service-'.$resource->id" <x-application.settings-section :id="'storage-service-'.$resource->uuid"
:title="Str::headline($resource->name)" :flush="true" :title="Str::headline($resource->name)" :flush="true"
helper="Volume mounts for this compose service. Compose-managed mounts are read-only in the dashboard."> helper="Volume mounts for this compose service. Compose-managed mounts are read-only in the dashboard.">
<x-slot:actions> <x-slot:actions>
@@ -47,7 +47,7 @@
x-transition:leave="ease-in duration-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95" x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full max-w-3xl overflow-hidden" class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full max-w-6xl overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)"> style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header> <header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -7,14 +7,10 @@
@endphp @endphp
<div class="flex w-full flex-col"> <div class="flex w-full flex-col">
@if ($isComposeOrService) @if (data_get($resource, 'build_pack') === 'dockercompose')
<div <div
class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-amber-800 dark:border-white/[0.08] dark:text-amber-300/90"> class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-amber-800 dark:border-white/[0.08] dark:text-amber-300/90">
@if ($resource->type() === 'service') Docker Compose volume mounts are read-only here. Edit the compose file and reload it to change volumes.
Service volume mounts are read-only here. Edit the Docker Compose file and reload it to change volumes.
@else
Docker Compose volume mounts are read-only here. Edit the compose file and reload it to change volumes.
@endif
</div> </div>
@endif @endif
@@ -30,6 +26,7 @@
PR suffix PR suffix
</span> </span>
@endif @endif
<span class="volumes-col-backup text-center">Backup</span>
@if ($showActionsColumn) @if ($showActionsColumn)
<span class="volumes-col-actions text-right">Actions</span> <span class="volumes-col-actions text-right">Actions</span>
@endif @endif
@@ -58,16 +55,6 @@
<span <span
class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg" class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $form['name'] }}">{{ $form['name'] }}</span> title="{{ $form['name'] }}">{{ $form['name'] }}</span>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else
<span class="table-badge table-badge-success shrink-0"
title="Volume backup is enabled">Backup</span>
@endif
@endif
</div> </div>
</div> </div>
@@ -91,6 +78,20 @@
</div> </div>
@endif @endif
<div class="volumes-col-backup data-table-cell-check">
<span class="volumes-mobile-label volumes-field-label">Backup</span>
@if ($hasEnabledBackup)
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</a>
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
@if ($showActionsColumn) @if ($showActionsColumn)
<div <div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5"> class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
@@ -115,16 +116,6 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.name" required /> <x-forms.input id="forms.{{ $id }}.name" required />
</div> </div>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else
<span class="table-badge table-badge-success shrink-0"
title="Volume backup is enabled">Backup</span>
@endif
@endif
</div> </div>
</div> </div>
@@ -149,6 +140,20 @@
</div> </div>
@endif @endif
<div class="volumes-col-backup data-table-cell-check">
<span class="volumes-mobile-label volumes-field-label">Backup</span>
@if ($hasEnabledBackup)
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</a>
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
<div <div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5"> class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
<x-forms.button type="submit" class="!px-2.5 !text-xs"> <x-forms.button type="submit" class="!px-2.5 !text-xs">
+1
View File
@@ -60,6 +60,7 @@ it('renders frontend-only application backup search data for volume names and fr
->assertSee('Target AZ') ->assertSee('Target AZ')
->assertSee('filterOpen') ->assertSee('filterOpen')
->assertSee('sortOpen') ->assertSee('sortOpen')
->assertDontSee('class="font-mono text-xs">daily', false)
->assertDontSee('backup-type-filter-trigger', false) ->assertDontSee('backup-type-filter-trigger', false)
->assertDontSee('backup-sort-trigger', false); ->assertDontSee('backup-sort-trigger', false);
}); });
@@ -0,0 +1,14 @@
<?php
it('shows a loading overlay while resource selection steps load', function () {
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
expect($view)
->toContain('wire:loading.class="pointer-events-none opacity-40 blur-[2px]"')
->toContain('wire:loading.flex')
->toContain('selectServer,selectDestination,selectProject,selectEnvironment')
->toContain('Loading selection…')
->toContain('isPaletteTransitioning')
->toContain('runPaletteTransition')
->toContain('Loading…');
});
@@ -131,6 +131,12 @@ it('uses the same eye-off2 icon in configuration changes expand toggle', functio
->not->toContain('M22.2954 6.31083'); ->not->toContain('M22.2954 6.31083');
}); });
it('uses a wide configuration changes dialog', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php'));
expect($view)->toContain('max-w-6xl');
});
it('renders env var password input before visibility toggle in tab order', function () { it('renders env var password input before visibility toggle in tab order', function () {
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />'); $html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
@@ -118,7 +118,9 @@ it('batches volume backup meta and exposes forms for every volume', function ()
$component = Livewire::test(All::class, ['resource' => $application]); $component = Livewire::test(All::class, ['resource' => $application]);
expect($component->get('volumeBackupMeta'))->toHaveCount(5) expect($component->get('volumeBackupMeta'))->toHaveCount(5)
->and($component->get('forms'))->toHaveCount(5); ->and($component->get('forms'))->toHaveCount(5)
->and($component->html())->toContain('volumes-col-backup')
->not->toContain('table-badge table-badge-success');
foreach ($component->get('volumeBackupMeta') as $meta) { foreach ($component->get('volumeBackupMeta') as $meta) {
expect($meta['enabled'])->toBeTrue() expect($meta['enabled'])->toBeTrue()
@@ -101,13 +101,15 @@ it('renders volumes as a data table with shared column headers', function () {
->toContain('Volume Name') ->toContain('Volume Name')
->toContain('Source Path') ->toContain('Source Path')
->toContain('Destination Path') ->toContain('Destination Path')
->toContain('volumes-col-backup')
->toContain('supportsPreviewSuffix') ->toContain('supportsPreviewSuffix')
->toContain('openBackupModal') ->toContain('openBackupModal')
->toContain('data-table-row') ->toContain('data-table-row')
->toContain('volumes-mobile-label') ->toContain('volumes-mobile-label')
->toContain('table-badge-success') ->not->toContain('table-badge table-badge-success')
->not->toContain('livewire:project.shared.storages.show') ->not->toContain('livewire:project.shared.storages.show')
->not->toContain('x-status-badge'); ->not->toContain('x-status-badge')
->not->toContain('Service volume mounts are read-only here.');
// Show remains available for isolated embeds/tests but is no longer nested from All. // Show remains available for isolated embeds/tests but is no longer nested from All.
expect($showView) expect($showView)
@@ -118,7 +120,16 @@ it('renders volumes as a data table with shared column headers', function () {
expect($storageView) expect($storageView)
->toContain('Str::headline($resource->name)') ->toContain('Str::headline($resource->name)')
->toContain(':flush="true"') ->toContain(':flush="true"')
->toContain('storage-service-'); ->toContain("'storage-service-'.\$resource->uuid");
$serviceConfigurationView = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
expect($serviceConfigurationView)
->toContain('storageSections')
->toContain('menu-subitem')
->toContain('scrollToSettingsSection')
->toContain('Service volume mounts are read-only here.')
->toContain("'storage-service-'.\$resource->uuid");
$css = file_get_contents(resource_path('css/app.css')); $css = file_get_contents(resource_path('css/app.css'));
@@ -131,6 +142,8 @@ it('renders volumes as a data table with shared column headers', function () {
->toContain('@media (max-width: 768px)') ->toContain('@media (max-width: 768px)')
->toContain('.table-badge-success'); ->toContain('.table-badge-success');
expect($css)->toContain('17.5rem');
// Settings form labels are 13px (not Tailwind text-sm 14px). // Settings form labels are 13px (not Tailwind text-sm 14px).
expect($css) expect($css)
->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s'); ->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s');
@@ -17,6 +17,22 @@ it('adds profile navigation with an appearance tab and route', function () {
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">'); ->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
}); });
it('opens the email change form without a Livewire request', function () {
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
expect($profileView)
->toContain('@click="openEmailModal()"')
->toContain('this.$refs.newEmailInput?.focus()')
->toContain('x-ref="newEmailInput"')
->toContain('x-show="emailModalOpen"')
->toContain('x-teleport="body"')
->toContain('Change email')
->toContain('Send code')
->toContain('Verify email')
->not->toContain('>Cancel</x-forms.button>')
->not->toContain('wire:click="showEmailChangeForm"');
});
it('keeps color theme preferences on the profile appearance view without page width or density controls', function () { it('keeps color theme preferences on the profile appearance view without page width or density controls', function () {
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php')); $appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
+22 -4
View File
@@ -185,7 +185,8 @@ it('auto-adds the suggested www pair when adding a service domain with both dire
->set('newDomain', 'https://web.example.com') ->set('newDomain', 'https://web.example.com')
->call('addDomain') ->call('addDomain')
->assertHasNoErrors() ->assertHasNoErrors()
->assertDispatched('success'); ->assertDispatched('success')
->assertSee('DNS skipped');
expect(explode(',', (string) $this->webApp->fresh()->fqdn)) expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com') ->toContain('https://web.example.com')
@@ -203,6 +204,22 @@ it('adds a domain to a selected service application', function () {
$this->webApp->refresh(); $this->webApp->refresh();
expect(explode(',', (string) $this->webApp->fqdn)) expect(explode(',', (string) $this->webApp->fqdn))
->toBe(['https://web.example.com', 'https://www.web.example.com']); ->toBe(['https://web.example.com', 'https://www.web.example.com']);
$dnsStatuses = $this->webApp->domain_dns_statuses;
expect($dnsStatuses)
->toHaveKey('https://web.example.com')
->toHaveKey('https://www.web.example.com')
->and($dnsStatuses['https://web.example.com']['status'])
->toBe('skipped')
->and($dnsStatuses['https://web.example.com']['checked_at'])
->not->toBeNull();
});
it('does not duplicate the service name as a badge in the domain cell', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
expect($view)->not->toContain('domains-service-mobile table-badge');
}); });
it('rolls back a domain change when compose regeneration fails', function () { it('rolls back a domain change when compose regeneration fails', function () {
@@ -273,7 +290,9 @@ it('prunes the previous dns status when a service domain is renamed', function (
$this->apiApp->refresh(); $this->apiApp->refresh();
expect($this->apiApp->fqdn)->toBe('https://renamed.example.com') expect($this->apiApp->fqdn)->toBe('https://renamed.example.com')
->and($this->apiApp->domain_dns_statuses)->toBeNull(); ->and($this->apiApp->domain_dns_statuses)->not->toHaveKey('https://api.example.com')
->and($this->apiApp->domain_dns_statuses)->toHaveKey('https://renamed.example.com')
->and($this->apiApp->domain_dns_statuses['https://renamed.example.com']['status'])->toBe('skipped');
}); });
it('does not restore stale dns status when a removed service domain is re-added', function () { it('does not restore stale dns status when a removed service domain is re-added', function () {
@@ -392,8 +411,7 @@ it('hides dns message text when service domain dns status is ok', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('DNS OK') ->assertSee('DNS OK')
->assertDontSee('DNS points to 203.0.113.10') ->assertDontSee('DNS points to 203.0.113.10');
->assertDontSee('Last checked');
}); });
it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) { it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) {