mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 08:23:28 +00:00
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:
@@ -167,6 +167,7 @@ class Index extends Component
|
||||
$this->show_verification = false;
|
||||
|
||||
$this->dispatch('success', 'Email address updated successfully.');
|
||||
$this->dispatch('close-email-change-modal');
|
||||
} else {
|
||||
$this->dispatch('error', 'Failed to update email address.');
|
||||
}
|
||||
@@ -231,12 +232,6 @@ class Index extends Component
|
||||
$this->dispatch('success', 'Email change request cancelled.');
|
||||
}
|
||||
|
||||
public function showEmailChangeForm()
|
||||
{
|
||||
$this->show_email_change = true;
|
||||
$this->new_email = '';
|
||||
}
|
||||
|
||||
public function resetPassword()
|
||||
{
|
||||
try {
|
||||
|
||||
+12
-7
@@ -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;
|
||||
}
|
||||
|
||||
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | [Actions] */
|
||||
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
|
||||
.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 {
|
||||
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 {
|
||||
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 {
|
||||
@@ -1927,7 +1927,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.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,
|
||||
@@ -1936,7 +1936,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.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,
|
||||
@@ -1945,7 +1945,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.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,
|
||||
@@ -2001,6 +2001,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.volumes-cell-name,
|
||||
.volumes-col-backup,
|
||||
.volumes-cell-dest,
|
||||
.volumes-cell-actions {
|
||||
display: flex;
|
||||
@@ -2010,6 +2011,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.volumes-col-backup {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.volumes-cell-actions {
|
||||
flex-direction: row;
|
||||
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="min-w-[640px]">
|
||||
<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>Current</div>
|
||||
<div></div>
|
||||
@@ -42,7 +42,7 @@
|
||||
@endphp
|
||||
|
||||
<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">
|
||||
@if ($rowExpandable)
|
||||
<div class="break-words"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
selectedIndex: -1,
|
||||
isSearching: false,
|
||||
isLoadingInitialData: false,
|
||||
isPaletteTransitioning: false,
|
||||
allSearchableItems: [],
|
||||
searchQuery: '',
|
||||
creatableItems: [],
|
||||
@@ -97,6 +98,7 @@
|
||||
this.isLoadingInitialData = false;
|
||||
this.searchQuery = '';
|
||||
this.allSearchableItems = [];
|
||||
this.isPaletteTransitioning = false;
|
||||
// Ensure scroll is restored
|
||||
document.body.style.overflow = '';
|
||||
// Use $wire instead of @this for SPA navigation compatibility
|
||||
@@ -104,6 +106,12 @@
|
||||
$wire.closeSearchModal();
|
||||
}
|
||||
},
|
||||
runPaletteTransition(callback) {
|
||||
this.isPaletteTransitioning = true;
|
||||
return Promise.resolve(callback()).finally(() => {
|
||||
this.isPaletteTransitioning = false;
|
||||
});
|
||||
},
|
||||
navigateResults(direction) {
|
||||
const results = document.querySelectorAll('.search-result-item');
|
||||
if (results.length === 0) return;
|
||||
@@ -162,7 +170,7 @@
|
||||
});
|
||||
|
||||
if (matchingItem && typeof $wire !== 'undefined' && $wire) {
|
||||
$wire.navigateToResource(matchingItem.type);
|
||||
this.runPaletteTransition(() => $wire.navigateToResource(matchingItem.type));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -313,14 +321,21 @@
|
||||
</div>
|
||||
|
||||
<!-- 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)
|
||||
<!-- 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)
|
||||
<div x-init="selectedIndex = -1">
|
||||
<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">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
</button>
|
||||
@@ -367,7 +382,7 @@
|
||||
@if ($selectedServerId !== null && $selectedDestinationUuid === null)
|
||||
<div x-init="selectedIndex = -1">
|
||||
<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">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
</button>
|
||||
@@ -415,7 +430,7 @@
|
||||
@if ($selectedDestinationUuid !== null && $selectedProjectUuid === null)
|
||||
<div x-init="selectedIndex = -1">
|
||||
<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">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
</button>
|
||||
@@ -463,7 +478,7 @@
|
||||
@if ($selectedProjectUuid !== null && $selectedEnvironmentUuid === null)
|
||||
<div x-init="selectedIndex = -1">
|
||||
<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">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
</button>
|
||||
@@ -509,6 +524,12 @@
|
||||
@endif
|
||||
</div>
|
||||
@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>
|
||||
@elseif ($isCreateMode && count($this->filteredCreatableItems) > 0 && !$autoOpenResource)
|
||||
<!-- Create mode (server-rendered path) -->
|
||||
@@ -569,7 +590,7 @@
|
||||
<div class="command-palette-section">
|
||||
<div class="command-palette-group-label">{{ $category }}</div>
|
||||
@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">
|
||||
@if (!empty($item['logo']))
|
||||
<div class="command-palette-item-icon">
|
||||
@@ -646,7 +667,7 @@
|
||||
<div class="command-palette-section">
|
||||
<div class="command-palette-group-label" x-text="categoryName"></div>
|
||||
<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">
|
||||
<template x-if="item.logo">
|
||||
<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-profile.navbar />
|
||||
|
||||
@@ -16,8 +23,8 @@
|
||||
<x-forms.input id="name" label="Name" required />
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.input id="email" label="Email" readonly />
|
||||
<x-forms.button wire:click="showEmailChangeForm" type="button"
|
||||
:disabled="$show_email_change || $show_verification">
|
||||
<x-forms.button @click="openEmailModal()" type="button"
|
||||
x-bind:disabled="emailModalOpen">
|
||||
Change
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@@ -25,53 +32,56 @@
|
||||
</section>
|
||||
</form>
|
||||
|
||||
@if ($show_email_change)
|
||||
<form wire:submit="requestEmailChange">
|
||||
<section class="application-settings-section">
|
||||
<div class="application-settings-section-header">
|
||||
<template x-teleport="body">
|
||||
<div x-show="emailModalOpen" x-cloak
|
||||
class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4">
|
||||
<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>
|
||||
<h2>Change email</h2>
|
||||
<p>A six-digit verification code will be sent to the new address.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="application-settings-section-body">
|
||||
<div class="flex max-w-xl items-end gap-2">
|
||||
<x-forms.input id="new_email" label="New email address" required type="email" />
|
||||
<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.
|
||||
<h3>{{ $show_verification ? 'Verify new email' : 'Change email' }}</h3>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
@if ($show_verification)
|
||||
Code sent to {{ $new_email ?: auth()->user()->pending_email }}.
|
||||
@else
|
||||
A six-digit verification code will be sent to the new address.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="application-settings-section-body">
|
||||
<div class="flex max-w-xl items-end gap-2">
|
||||
<button type="button"
|
||||
@click="@if ($show_verification) $wire.cancelEmailChange().then(() => emailModalOpen = false) @else emailModalOpen = false @endif"
|
||||
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
|
||||
inputmode="numeric" maxlength="6" />
|
||||
<x-forms.button type="submit">Verify email</x-forms.button>
|
||||
<x-forms.button wire:click="resendVerificationCode" type="button">Resend</x-forms.button>
|
||||
<x-forms.button wire:click="cancelEmailChange" type="button">Cancel</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
@endif
|
||||
<p class="text-xs text-neutral-500 dark:text-fg-dim">
|
||||
The code expires after
|
||||
{{ config('constants.email_change.verification_code_expiry_minutes', 10) }} minutes.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<x-forms.button wire:click="resendVerificationCode" type="button">Resend code</x-forms.button>
|
||||
<x-forms.button type="submit" isHighlighted>Verify email</x-forms.button>
|
||||
</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">
|
||||
<section class="application-settings-section">
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
{{ $backup->targetName() }}
|
||||
</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>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
$storageSections = $applications
|
||||
->concat($databases)
|
||||
->map(fn ($resource): array => [
|
||||
'id' => 'storage-service-'.$resource->uuid,
|
||||
'label' => Str::headline($resource->name),
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<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" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</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
|
||||
</nav>
|
||||
@@ -112,6 +132,10 @@
|
||||
<livewire:project.shared.environment-variable.all :resource="$service" />
|
||||
@elseif ($currentRoute === 'project.service.storages')
|
||||
<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)
|
||||
<livewire:project.service.storage wire:key="application-{{ $application->id }}"
|
||||
:resource="$application" />
|
||||
|
||||
@@ -67,9 +67,6 @@
|
||||
@if ($isSuggested && ! empty($row['suggestion_label']))
|
||||
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
|
||||
@endif
|
||||
@if ($showServiceColumn)
|
||||
<span class="domains-service-mobile table-badge shrink-0">{{ $serviceLabel }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($isSuggested && filled($row['dns_message']))
|
||||
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
|
||||
|
||||
@@ -416,7 +416,7 @@
|
||||
</x-application.settings-section>
|
||||
@else
|
||||
{{-- 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"
|
||||
helper="Volume mounts for this compose service. Compose-managed mounts are read-only in the dashboard.">
|
||||
<x-slot:actions>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
x-transition:leave="ease-in duration-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"
|
||||
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)">
|
||||
<header>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -7,14 +7,10 @@
|
||||
@endphp
|
||||
|
||||
<div class="flex w-full flex-col">
|
||||
@if ($isComposeOrService)
|
||||
@if (data_get($resource, 'build_pack') === 'dockercompose')
|
||||
<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">
|
||||
@if ($resource->type() === 'service')
|
||||
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
|
||||
Docker Compose volume mounts are read-only here. Edit the compose file and reload it to change volumes.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -30,6 +26,7 @@
|
||||
PR suffix
|
||||
</span>
|
||||
@endif
|
||||
<span class="volumes-col-backup text-center">Backup</span>
|
||||
@if ($showActionsColumn)
|
||||
<span class="volumes-col-actions text-right">Actions</span>
|
||||
@endif
|
||||
@@ -58,16 +55,6 @@
|
||||
<span
|
||||
class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
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>
|
||||
|
||||
@@ -91,6 +78,20 @@
|
||||
</div>
|
||||
@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)
|
||||
<div
|
||||
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">
|
||||
<x-forms.input id="forms.{{ $id }}.name" required />
|
||||
</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>
|
||||
|
||||
@@ -149,6 +140,20 @@
|
||||
</div>
|
||||
@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
|
||||
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">
|
||||
|
||||
@@ -60,6 +60,7 @@ it('renders frontend-only application backup search data for volume names and fr
|
||||
->assertSee('Target A–Z')
|
||||
->assertSee('filterOpen')
|
||||
->assertSee('sortOpen')
|
||||
->assertDontSee('class="font-mono text-xs">daily', false)
|
||||
->assertDontSee('backup-type-filter-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');
|
||||
});
|
||||
|
||||
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 () {
|
||||
$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]);
|
||||
|
||||
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) {
|
||||
expect($meta['enabled'])->toBeTrue()
|
||||
|
||||
@@ -101,13 +101,15 @@ it('renders volumes as a data table with shared column headers', function () {
|
||||
->toContain('Volume Name')
|
||||
->toContain('Source Path')
|
||||
->toContain('Destination Path')
|
||||
->toContain('volumes-col-backup')
|
||||
->toContain('supportsPreviewSuffix')
|
||||
->toContain('openBackupModal')
|
||||
->toContain('data-table-row')
|
||||
->toContain('volumes-mobile-label')
|
||||
->toContain('table-badge-success')
|
||||
->not->toContain('table-badge table-badge-success')
|
||||
->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.
|
||||
expect($showView)
|
||||
@@ -118,7 +120,16 @@ it('renders volumes as a data table with shared column headers', function () {
|
||||
expect($storageView)
|
||||
->toContain('Str::headline($resource->name)')
|
||||
->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'));
|
||||
|
||||
@@ -131,6 +142,8 @@ it('renders volumes as a data table with shared column headers', function () {
|
||||
->toContain('@media (max-width: 768px)')
|
||||
->toContain('.table-badge-success');
|
||||
|
||||
expect($css)->toContain('17.5rem');
|
||||
|
||||
// Settings form labels are 13px (not Tailwind text-sm 14px).
|
||||
expect($css)
|
||||
->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">');
|
||||
});
|
||||
|
||||
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 () {
|
||||
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
|
||||
|
||||
|
||||
@@ -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')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
->assertDispatched('success')
|
||||
->assertSee('DNS skipped');
|
||||
|
||||
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
|
||||
->toContain('https://web.example.com')
|
||||
@@ -203,6 +204,22 @@ it('adds a domain to a selected service application', function () {
|
||||
$this->webApp->refresh();
|
||||
expect(explode(',', (string) $this->webApp->fqdn))
|
||||
->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 () {
|
||||
@@ -273,7 +290,9 @@ it('prunes the previous dns status when a service domain is renamed', function (
|
||||
$this->apiApp->refresh();
|
||||
|
||||
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 () {
|
||||
@@ -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'])])
|
||||
->assertSee('DNS OK')
|
||||
->assertDontSee('DNS points to 203.0.113.10')
|
||||
->assertDontSee('Last checked');
|
||||
->assertDontSee('DNS points to 203.0.113.10');
|
||||
});
|
||||
|
||||
it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) {
|
||||
|
||||
Reference in New Issue
Block a user