From 3f47d4188037a2367c506f88cb4371a420508866 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:33:11 +0200 Subject: [PATCH 01/10] feat(ui): improve terminal mobile UX and server status feedback Add keyboard-aware terminal controls, refine terminal layouts and navigation, surface proxy and Sentinel health warnings, and make avatar uploads update only after successful persistence. --- .env.development.example | 1 + app/Livewire/Profile/Index.php | 6 +- config/constants.php | 1 + database/seeders/SentinelSeeder.php | 8 ++ resources/css/app.css | 29 +++-- resources/js/terminal.js | 121 +++++++++++++++++- .../components/modal-confirmation.blade.php | 3 +- .../views/components/server/sidebar.blade.php | 2 +- resources/views/livewire/dashboard.blade.php | 4 + .../views/livewire/profile/index.blade.php | 44 +++++-- .../project/shared/terminal.blade.php | 58 ++++----- .../views/livewire/server/index.blade.php | 6 +- .../views/livewire/server/navbar.blade.php | 2 +- tests/Feature/DeploymentLogsLayoutTest.php | 2 + tests/Feature/ModalScrollLockTest.php | 9 ++ tests/Feature/ProfileAvatarTest.php | 11 +- .../Feature/RealtimeTerminalPackagingTest.php | 113 +++++++++++----- tests/Feature/SentinelSeederTest.php | 28 ++++ .../ServerStatusIndicatorDesignTest.php | 14 +- tests/Feature/TerminalPageHeaderTest.php | 10 ++ 20 files changed, 367 insertions(+), 105 deletions(-) create mode 100644 tests/Feature/ModalScrollLockTest.php create mode 100644 tests/Feature/SentinelSeederTest.php diff --git a/.env.development.example b/.env.development.example index 29162a59a..6c0fe2189 100644 --- a/.env.development.example +++ b/.env.development.example @@ -9,6 +9,7 @@ APP_PORT=8000 APP_DEBUG=true SSH_MUX_ENABLED=true COOLIFY_CONTAINER_ROLE=all +DEV_SENTINEL_URL= # PostgreSQL Database Configuration DB_DATABASE=coolify diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 99c2567f2..a20a1231b 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -38,7 +38,7 @@ class Index extends Component public $avatar; - public function uploadAvatar(AvatarStorageService $avatarStorage): void + public function uploadAvatar(AvatarStorageService $avatarStorage): bool { try { $this->validate([ @@ -49,8 +49,12 @@ class Index extends Component $this->reset('avatar'); $this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp])); $this->dispatch('success', 'Profile picture updated.'); + + return true; } catch (\Throwable $e) { handleError($e, $this); + + return false; } } diff --git a/config/constants.php b/config/constants.php index aa1b5c36c..fc3179059 100644 --- a/config/constants.php +++ b/config/constants.php @@ -99,6 +99,7 @@ return [ ], 'sentinel' => [ + 'dev_url' => env('DEV_SENTINEL_URL'), // How often (seconds) PushServerUpdateJob is force-dispatched even when // the container state hash is unchanged. Keeps exited-detection and // storage checks from going stale without writing every resource row on diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php index 3cf913933..ebae97078 100644 --- a/database/seeders/SentinelSeeder.php +++ b/database/seeders/SentinelSeeder.php @@ -16,6 +16,14 @@ class SentinelSeeder extends Seeder if (str($server->settings->sentinel_token)->isEmpty()) { $server->settings->generateSentinelToken(ignoreEvent: true); } + $developmentUrl = isDev() ? config('constants.sentinel.dev_url') : null; + if (filled($developmentUrl)) { + $server->settings->sentinel_custom_url = $developmentUrl; + $server->settings->saveQuietly(); + + continue; + } + if (str($server->settings->sentinel_custom_url)->isEmpty()) { $url = $server->settings->generateSentinelUrl(ignoreEvent: true); if (str($url)->isEmpty()) { diff --git a/resources/css/app.css b/resources/css/app.css index 1d73bf328..ad3412bf2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -78,7 +78,13 @@ @layer components { .terminal-mobile-key { - @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25; + @apply min-h-8 shrink-0 rounded-full border bg-transparent px-3 py-1 text-sm font-medium text-neutral-300 active:text-white; + border-color: color-mix(in srgb, var(--terminal-scrollbar, #fff) 24%, transparent); + } + + .terminal-key-row { + border: 1px solid color-mix(in srgb, var(--terminal-scrollbar, #fff) 22%, transparent); + background: transparent; } /* Active state is a solid fill only (no accent rail / border). */ @@ -714,17 +720,6 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina color: #52525b; } -.terminal-session-expiry { - font-size: 0.75rem; - font-weight: 500; - color: rgb(255 255 255 / 0.6); -} - -html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-expiry, -html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry { - color: #52525b; -} - .terminal-target-picker { color: rgb(255 255 255 / 0.75); background: rgb(0 0 0 / 0.18); @@ -2457,6 +2452,16 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr); } +@media (min-width: 1024px) { + .deployment-table-scroll { + overflow-x: visible; + } + + .deployment-table-grid { + min-width: 0; + } +} + .dashboard-deployment-table-grid { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem; } diff --git a/resources/js/terminal.js b/resources/js/terminal.js index bdcc3824d..766bd5f86 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -196,6 +196,14 @@ export function initializeTerminalComponent() { isDocumentVisible: true, wasConnectedBeforeHidden: false, mobileToolbarCollapsed: false, + terminalModifier: null, + keyboardInset: 0, + keyboardAnchorTop: 0, + keyboardViewportHeight: 0, + keyboardViewportWidth: 0, + keyboardInsetSettleTimeout: null, + updateKeyboardInset: null, + syncKeyboardInset: null, // Inline style snapshots for ancestors unlocked while fullscreen (no DOM reparenting). fullscreenAncestorPatches: null, pageScrollLocked: false, @@ -212,6 +220,53 @@ export function initializeTerminalComponent() { init() { this.starting = this.$el.dataset.autoStart === 'true'; + this.updateKeyboardInset = () => { + const viewport = window.visualViewport; + const viewportWidth = viewport?.width ?? window.innerWidth; + const layoutHeight = Math.max( + window.innerHeight, + document.documentElement.clientHeight, + viewport ? viewport.height + viewport.offsetTop : 0, + ); + + // Track the tallest viewport seen at this width — an open software + // keyboard shrinks the visual viewport well below it. A large width + // change (rotation) resets the baseline. + if (Math.abs(this.keyboardViewportWidth - viewportWidth) > 80) { + this.keyboardViewportHeight = layoutHeight; + } else { + this.keyboardViewportHeight = Math.max(this.keyboardViewportHeight, layoutHeight); + } + this.keyboardViewportWidth = viewportWidth; + + const visualBottom = viewport ? viewport.height + viewport.offsetTop : layoutHeight; + this.keyboardInset = window.innerWidth < 640 && viewport + ? Math.max(0, Math.round(this.keyboardViewportHeight - visualBottom)) + : 0; + // position:fixed resolves `top` against the layout viewport and + // visualViewport.offsetTop is relative to it, so offsetTop + height + // is the exact bottom edge of the visible area — a toolbar pinned at + // this anchor rides on top of the keyboard no matter how the browser + // reports keyboard geometry (iOS overlay or Android layout resize). + this.keyboardAnchorTop = Math.round(visualBottom); + + this.syncFullscreenShellWithKeyboard(viewport); + + if (this.fullscreen) { + this.$nextTick(() => this.resizeTerminal()); + } + }; + this.syncKeyboardInset = () => { + // iOS fires viewport events mid keyboard animation — re-measure once + // the keyboard settles. + this.updateKeyboardInset(); + clearTimeout(this.keyboardInsetSettleTimeout); + this.keyboardInsetSettleTimeout = setTimeout(this.updateKeyboardInset, 250); + }; + this.updateKeyboardInset(); + window.visualViewport?.addEventListener('resize', this.syncKeyboardInset); + window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset); + window.addEventListener('resize', this.syncKeyboardInset); this.themeObserver = new MutationObserver(() => { if (this.selectedTheme === 'system') { applicationTerminalThemes.system = createSystemTerminalTheme(); @@ -257,7 +312,7 @@ export function initializeTerminalComponent() { } this.$nextTick(() => { if (active) { - this.$refs.terminalWrapper.style.display = 'block'; + this.$refs.terminalWrapper.style.removeProperty('display'); this.resizeTerminal(); // Start observing terminal wrapper for resize changes @@ -266,8 +321,11 @@ export function initializeTerminalComponent() { } } else { const terminalElement = document.getElementById('terminal'); - this.$refs.terminalWrapper.style.display = - terminalElement?.dataset.terminalStyle === 'application' ? 'block' : 'none'; + if (terminalElement?.dataset.terminalStyle === 'application') { + this.$refs.terminalWrapper.style.removeProperty('display'); + } else { + this.$refs.terminalWrapper.style.display = 'none'; + } // Stop observing when terminal is inactive if (this.resizeObserver) { @@ -305,6 +363,10 @@ export function initializeTerminalComponent() { }, cleanup() { + window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset); + window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset); + window.removeEventListener('resize', this.syncKeyboardInset); + clearTimeout(this.keyboardInsetSettleTimeout); this.checkIfProcessIsRunningAndKillIt(); this.clearAllTimers(); this.connectionState = 'disconnected'; @@ -848,6 +910,10 @@ export function initializeTerminalComponent() { destroy() { this.themeObserver?.disconnect(); + window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset); + window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset); + window.removeEventListener('resize', this.syncKeyboardInset); + clearTimeout(this.keyboardInsetSettleTimeout); }, @@ -856,7 +922,6 @@ export function initializeTerminalComponent() { return; } - this.term.focus(); this.sendMessage({ message: data }); }, @@ -868,14 +933,35 @@ export function initializeTerminalComponent() { arrowLeft: '\x1b[D', tab: '\t', escape: '\x1b', - ctrlC: '\x03' + ctrlC: '\x03', + ctrlBackslash: '\x1c', + ctrlS: '\x13', + ctrlZ: '\x1a' }; if (terminalSequences[sequence]) { + this.terminalModifier = null; this.sendTerminalInput(terminalSequences[sequence]); } }, + toggleTerminalModifier(modifier) { + this.terminalModifier = this.terminalModifier === modifier ? null : modifier; + }, + + sendTerminalKey(key) { + let input = key; + + if (this.terminalModifier === 'ctrl') { + input = String.fromCharCode(key.toUpperCase().charCodeAt(0) & 31); + } else if (this.terminalModifier === 'alt') { + input = `\x1b${key}`; + } + + this.terminalModifier = null; + this.sendTerminalInput(input); + }, + async pasteFromClipboard() { if (!navigator.clipboard?.readText) { this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.'); @@ -979,6 +1065,29 @@ export function initializeTerminalComponent() { this.sendMessage({ checkActive: 'force' }); }, + /** + * While the software keyboard is open, shrink the fullscreen shell to the + * visual viewport so xterm rows and the mobile key row stay visible above + * the keyboard. Inline !important is required to outrank the stylesheet's + * `inset: 0 !important` / `height: auto !important` fullscreen rules. + */ + syncFullscreenShellWithKeyboard(viewport) { + const wrapper = this.$refs.terminalWrapper; + if (!wrapper) { + return; + } + + if (this.fullscreen && viewport && this.keyboardInset > 0) { + wrapper.style.setProperty('top', `${Math.round(viewport.offsetTop)}px`, 'important'); + wrapper.style.setProperty('height', `${Math.round(viewport.height)}px`, 'important'); + wrapper.style.setProperty('bottom', 'auto', 'important'); + } else { + wrapper.style.removeProperty('top'); + wrapper.style.removeProperty('height'); + wrapper.style.removeProperty('bottom'); + } + }, + makeFullscreen() { if (this.fullscreen) { this.exitFullscreen(); @@ -1012,6 +1121,7 @@ export function initializeTerminalComponent() { this.fullscreen = true; document.documentElement.classList.add('terminal-is-fullscreen'); document.body.classList.add('terminal-is-fullscreen'); + this.updateKeyboardInset?.(); this.scheduleTerminalResize(); }, @@ -1032,6 +1142,7 @@ export function initializeTerminalComponent() { // Recover from older portal builds that left the terminal on . this.salvageStrayFullscreenNodes(); + this.updateKeyboardInset?.(); this.scheduleTerminalResize(); }, diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 4c0a63b78..8cc233a0c 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -328,7 +328,8 @@ class="w-auto" isError @click=" if (dispatchEvent) { - $wire.dispatch(dispatchEventType, dispatchEventMessage); + modalOpen = false; + $nextTick(() => $wire.dispatch(dispatchEventType, dispatchEventMessage)); } if (confirmWithPassword && !skipPasswordConfirmation) { step++; diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index d0254054b..006efb2ca 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -83,7 +83,7 @@ [ 'label' => 'Terminal', 'route' => 'server.command', - 'active' => request()->routeIs('server.command'), + 'active' => $activeMenu === 'terminal', 'icon' => 'browser-terminal', 'group' => 'Operations', 'navigate' => false, diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 652dbb0e2..eb48314bf 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -169,11 +169,15 @@
@foreach ($dashboardServers as $server) @php + $proxyNeedsAttention = $server->proxySet() && $server->proxy->status !== 'running'; + $sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive(); + [$serverStatus, $serverStatusType] = match (true) { $server->settings->force_disabled => ['Disabled', 'error'], ! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'], ! $server->settings->is_reachable => ['Unreachable', 'error'], ! $server->settings->is_usable => ['Not ready', 'warning'], + $proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'], default => ['Ready', 'success'], }; @endphp diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 090be9fd9..8ad2e9207 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -52,9 +52,25 @@ const blob = await new Promise((resolve, reject) => { canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8); }); - this.preview = URL.createObjectURL(blob); + const previewUrl = URL.createObjectURL(blob); const compressed = new File([blob], 'avatar.jpg', { type: 'image/jpeg' }); - this.$wire.upload('avatar', compressed, () => this.processing = false, () => { + this.$wire.upload('avatar', compressed, async () => { + try { + const uploaded = await this.$wire.uploadAvatar(); + if (uploaded) { + if (this.preview) URL.revokeObjectURL(this.preview); + this.preview = previewUrl; + } else { + URL.revokeObjectURL(previewUrl); + } + } catch (error) { + URL.revokeObjectURL(previewUrl); + this.uploadError = 'The image could not be uploaded.'; + } finally { + this.processing = false; + } + }, () => { + URL.revokeObjectURL(previewUrl); this.processing = false; this.uploadError = 'The image could not be uploaded.'; }); @@ -84,22 +100,22 @@ @endif
- +
+ + + + + @if (auth()->user()->avatar_path) + Remove + @endif +

@error('avatar')

{{ $message }}

@enderror -
- - Upload picture - Compressing… - - @if (auth()->user()->avatar_path) - Remove - @endif -
diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index a96a28ab1..e219ceef5 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -60,10 +60,6 @@ x-text="connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : (starting ? 'connecting…' : (connectionState === 'connecting' ? 'connecting…' : 'choose a container to start a session'))"> -
-
@else
-
-
- Terminal keys - -
-
- - - - - - -
+ :class="fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')" + :style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : ''" + data-terminal-mobile-toolbar> +
+ + + + + + + + + + + + + +
@@ -124,7 +114,7 @@ ') + ->toContain("toggleTerminalModifier('ctrl')") + ->toContain("toggleTerminalModifier('alt')") + ->toContain("sendTerminalKey('/')") + ->toContain("sendTerminalKey('|')") + ->toContain("sendTerminalKey('~')") + ->toContain("sendTerminalKey('-')") + ->toContain("sendTerminalControl('ctrlC')") + ->toContain("sendTerminalControl('ctrlBackslash')") + ->toContain("sendTerminalControl('ctrlS')") + ->toContain("sendTerminalControl('ctrlZ')") + ->not->toContain("sendTerminalControl('arrowUp')") + ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'") ->toContain('data-terminal-mobile-toolbar') ->and($appCss) - ->toContain('.terminal-mobile-key'); + ->toContain('.terminal-mobile-key') + ->toContain('min-h-8') + ->toContain('rounded-full') + ->toContain('.terminal-key-row') + ->toContain('background: transparent;') + ->toContain('var(--terminal-scrollbar'); +}); + +it('shows the terminal key row outside fullscreen mode', function () { + $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + $terminalClient = file_get_contents(resource_path('js/terminal.js')); + + expect($terminalView) + ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'") + ->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar') + ->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"') + ->and($terminalClient) + ->toContain("this.\$refs.terminalWrapper.style.removeProperty('display')") + ->not->toContain("this.\$refs.terminalWrapper.style.display = 'block'"); }); it('sends terminal mobile toolbar controls through the websocket', function () { @@ -365,8 +384,15 @@ it('sends terminal mobile toolbar controls through the websocket', function () { ->toContain("tab: '\\t'") ->toContain("escape: '\\x1b'") ->toContain("ctrlC: '\\x03'") + ->toContain("ctrlBackslash: '\\x1c'") + ->toContain("ctrlS: '\\x13'") + ->toContain("ctrlZ: '\\x1a'") + ->toContain('toggleTerminalModifier(modifier)') + ->toContain('sendTerminalKey(key)') ->toContain('navigator.clipboard.readText()') - ->toContain('navigator.clipboard.writeText(selection)'); + ->toContain('navigator.clipboard.writeText(selection)') + ->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.sendMessage({ message: data });") + ->not->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.term.focus();"); }); it('uses terminal host dimensions when resizing so mobile controls do not cover terminal rows', function () { @@ -379,24 +405,40 @@ it('uses terminal host dimensions when resizing so mobile controls do not cover ->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;'); }); -it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () { +it('keeps the fullscreen mobile toolbar above the software keyboard', function () { $terminalClient = file_get_contents(resource_path('js/terminal.js')); $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); expect($terminalClient) - ->not->toContain('updateFullscreenLayout()') - ->not->toContain('terminalFullscreenHeight') - ->not->toContain('window.visualViewport?.height') + ->toContain('keyboardInset: 0') + ->toContain('keyboardAnchorTop: 0') + ->toContain('keyboardViewportHeight: 0') + ->toContain('updateKeyboardInset()') + ->toContain('window.visualViewport') + ->toContain('viewport.height + viewport.offsetTop') + ->toContain('this.keyboardViewportHeight - visualBottom') + ->toContain('this.keyboardAnchorTop = Math.round(visualBottom)') + ->toContain('syncFullscreenShellWithKeyboard(viewport)') + ->toContain("wrapper.style.setProperty('bottom', 'auto', 'important')") + ->toContain("window.visualViewport?.addEventListener('resize', this.syncKeyboardInset)") + ->toContain("window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset)") + ->toContain("window.addEventListener('resize', this.syncKeyboardInset)") + ->toContain("window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset)") + ->toContain("window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset)") + ->toContain("window.removeEventListener('resize', this.syncKeyboardInset)") ->and($terminalView) - ->toContain("mobileToolbarCollapsed\n ? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-14'\n : 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24'") - ->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2'"); + ->toContain("'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'") + ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2'") + ->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"') + ->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2'"); }); -it('resizes after toggling the mobile terminal toolbar', function () { - $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); +it('resizes after the mobile keyboard viewport changes', function () { + $terminalClient = file_get_contents(resource_path('js/terminal.js')); - expect($terminalView) - ->toContain('$nextTick(() => resizeTerminal())'); + expect($terminalClient) + ->toContain('window.visualViewport') + ->toContain('this.$nextTick(() => this.resizeTerminal())'); }); it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () { @@ -438,6 +480,13 @@ it('keeps enter and exit fullscreen controls the same size and chrome', function ->toContain('color-mix(in srgb, var(--terminal-scrollbar'); }); +it('keeps the application terminal fullscreen control visible on mobile', function () { + $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + + expect($terminalView) + ->toContain('opacity-100 sm:opacity-0 sm:group-hover/terminal:opacity-100 sm:focus-visible:opacity-100'); +}); + it('lets the selected theme show through the active terminal panel', function () { $appCss = file_get_contents(resource_path('css/app.css')); $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); diff --git a/tests/Feature/SentinelSeederTest.php b/tests/Feature/SentinelSeederTest.php new file mode 100644 index 000000000..b905bfcbe --- /dev/null +++ b/tests/Feature/SentinelSeederTest.php @@ -0,0 +1,28 @@ +insert(['id' => 0]); + $user = User::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $user->teams()->first()->id, + ]); + DB::table('server_settings')->where('id', $server->settings->id)->update([ + 'sentinel_custom_url' => 'http://host.docker.internal:8000', + ]); + + config()->set('app.env', 'local'); + config()->set('constants.sentinel.dev_url', 'https://coolify-dev.example.com:8000'); + + app(SentinelSeeder::class)->run(); + + expect($server->settings->fresh()->sentinel_custom_url) + ->toBe('https://coolify-dev.example.com:8000'); +}); diff --git a/tests/Feature/ServerStatusIndicatorDesignTest.php b/tests/Feature/ServerStatusIndicatorDesignTest.php index e29857c49..34e0f4cbd 100644 --- a/tests/Feature/ServerStatusIndicatorDesignTest.php +++ b/tests/Feature/ServerStatusIndicatorDesignTest.php @@ -14,9 +14,12 @@ test('server cards use icon borders instead of ready badges', function () { expect(substr_count($serverIndex, 'toBe(1) ->and($serverIndex) + ->toContain("\$proxyNeedsAttention = \$isReady && \$server->proxySet() && \$server->proxy->status !== 'running'") + ->toContain('$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive()') + ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => 'warning'") ->toContain("\$isReady => 'success'") ->toContain("\$isTransferredAway || \$server->settings->force_disabled => 'error'") - ->toContain("default => 'warning'") + ->toContain("default => 'error'") ->toContain("server.statusType === 'success' ? 'border-emerald-500/70'") ->toContain("server.statusType === 'warning' ? 'border-amber-500/70'") ->toContain("'border-red-500/70'") @@ -24,6 +27,15 @@ test('server cards use icon borders instead of ready badges', function () { ->toContain(':aria-label="`Server status: ${server.status}`"'); }); +test('dashboard server cards warn when proxy or sentinel needs attention', function () { + $dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php')); + + expect($dashboard) + ->toContain("\$proxyNeedsAttention = \$server->proxySet() && \$server->proxy->status !== 'running'") + ->toContain('$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive()') + ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => ['Attention required', 'warning']"); +}); + test('server table keeps status text without a badge', function () { $serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php')); diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php index d4bfd925a..6e1308608 100644 --- a/tests/Feature/TerminalPageHeaderTest.php +++ b/tests/Feature/TerminalPageHeaderTest.php @@ -80,6 +80,16 @@ it('opens the global terminal outside Livewire navigation like resource terminal ->not->toMatch('/]*wireNavigate\(\)/s'); }); +it('keeps the server terminal navigation active during Livewire requests', function () { + $sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php')); + $navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php')); + + expect($sidebar) + ->toContain("'active' => \$activeMenu === 'terminal'") + ->and($navbar) + ->toContain("'active' => \$currentRoute === 'server.command'"); +}); + it('uses floating rounded controls instead of the legacy terminal header bar', function () { $view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php')); From 41b3ed95aac04c84b60abf667729ca840951385c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:28:46 +0200 Subject: [PATCH 02/10] fix(ui): stabilize domain controls and unify resource navigation Preserve optimistic listbox selections during Livewire saves, refresh service domains after updates, and align application, service, and server navigation layouts. --- DESIGN.md | 28 + UI_REDESIGN.md | 687 ------------------ app/Livewire/Project/Service/Domains.php | 1 + resources/css/app.css | 65 +- .../applications/advanced.blade.php | 26 - .../views/components/forms/listbox.blade.php | 26 +- .../forms/searchable-listbox.blade.php | 19 +- resources/views/components/reicon.blade.php | 1 + .../components/services/advanced.blade.php | 51 -- .../project/application/domains.blade.php | 32 +- .../project/application/heading.blade.php | 44 +- .../application/partials/domain-row.blade.php | 31 +- .../project/service/heading.blade.php | 89 ++- .../service/partials/domain-table.blade.php | 28 +- .../views/livewire/server/navbar.blade.php | 126 ++-- tests/Feature/ApplicationDomainsTest.php | 44 +- .../Feature/ListboxTriggerTruncationTest.php | 25 + .../ResourceHeadingUnifiedNavbarTest.php | 51 +- .../SearchableListboxComponentTest.php | 12 + .../Feature/ServerNavbarStatusLayoutTest.php | 17 + tests/Feature/ServiceDomainsTest.php | 15 +- 21 files changed, 464 insertions(+), 954 deletions(-) delete mode 100644 UI_REDESIGN.md delete mode 100644 resources/views/components/applications/advanced.blade.php delete mode 100644 resources/views/components/services/advanced.blade.php diff --git a/DESIGN.md b/DESIGN.md index 3976dd12b..5546b28f0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -399,6 +399,34 @@ do not create an unnecessarily wide menu. Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The selected option is indicated inside the menu, not repeated on the trigger. +#### Livewire dropdown state synchronization + +Instant-save listboxes must not flash back to an older value while Livewire is +saving or morphing the DOM. Treat the Alpine selection as the current visual +state until its request finishes: + +- await the Livewire change handler and prevent overlapping selections while + it is running; +- when a client-managed listbox can be rerendered by an unrelated or stale + Livewire response, use the listbox's `preserveValue` option so the morph does + not replace its newer Alpine value; +- scope `preserveValue` to controls whose value is owned by that interaction; + do not use it when external server events must replace the displayed value; +- after saving through a related model, refresh the parent component's loaded + relationship before rendering the response. A database write alone does not + update an already-loaded Eloquent collection; +- use stable `wire:key` values for rows containing listboxes. Do not include the + selected value in the key, because recreating the Alpine component causes a + visible reset; +- remember that a portalled options panel is teleported outside its visual + wrapper. Guard selection in the Alpine handler itself rather than relying + only on `pointer-events` or a disabled wrapper. + +The failure mode to avoid is: selection B is shown optimistically, selection A +is chosen next, the response for B morphs the listbox back to B, then the later +response finally shows A. The control should remain on the newest accepted +selection throughout the save sequence. + #### Multi-select filter dropdowns Toolbar filters that can combine criteria use one multi-select listbox rather diff --git a/UI_REDESIGN.md b/UI_REDESIGN.md deleted file mode 100644 index bc690cd1c..000000000 --- a/UI_REDESIGN.md +++ /dev/null @@ -1,687 +0,0 @@ -# Coolify UI redesign - -This branch restyles Coolify without changing its Livewire + Blade + Alpine + -Tailwind v4 architecture. The visual system now covers the global shell, -project and environment pages, application navigation, settings surfaces, -tables, modals, toasts, terminals, and metrics. - -Use this file as the source of truth when updating another page. The older -Graphite-only notes are no longer accurate. - -Onboarding validation and live server validation checkpoints share -`` (idle / pending / running / success / error) inside a -compact divided list, not legacy green check SVGs or fixed-width status rows. - -> **Maintainer rules** -> -> - Keep the work frontend-focused unless existing data must be exposed to the -> view. -> - Preserve routes, Livewire bindings, permissions, confirmations, and working -> interactions while changing layout and presentation. -> - Do not write or run tests for this redesign branch. -> - Validate Blade with `docker exec coolify php artisan view:cache`, then clear -> it with `docker exec coolify php artisan view:clear`. -> - Build frontend assets in the Vitee container with -> `docker exec coolify-vite npm run build`. -> - Use existing components before adding another styling abstraction. -> - Use `` for dropdown controls. Never add a native -> `` on any redesigned route, including mobile -fallbacks. Use: - -```blade - -``` - -Boolean checkboxes should normally become descriptive two-option listboxes. -Use `.live` behavior only when the selection needs an immediate server -rerender. - -Keep checkboxes for compact permission matrices and multi-select lists. Those -controls must use the shared `x-forms.checkbox` anatomy: an 18px rounded custom -box, purple checked fill in light mode, yellow checked fill in dark mode, and a -high-contrast check mark. Never expose the browser or Tailwind Forms default -checkbox on a redesigned page. - -The popup panel uses a 10px radius around 6px options with a 4px inset. Keep -the option content left-aligned and size the panel to its content or trigger; -do not create an unnecessarily wide menu. - -Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The -selected option is indicated inside the menu, not repeated on the trigger. - -#### Multi-select filter dropdowns - -Toolbar filters that can combine criteria use one multi-select listbox rather -than separate dropdowns or a single selected value. Follow the deployment -history filter in -`resources/views/livewire/project/application/deployment/index.blade.php`: - -- set `aria-multiselectable="true"` on the listbox; -- group related options under compact uppercase labels; -- keep the dropdown open while options are toggled; -- use the shared 16px custom checkbox treatment: purple checked fill in light - mode, yellow checked fill in dark mode, and a high-contrast check mark; -- show the number of active selections in a small count pill on the static - `Filter` trigger; -- combine selections within one group with OR logic and combine different - groups with AND logic; -- constrain only the options area with `max-h-80 overflow-y-auto`; -- place a persistent `Reset filters` action in a separate footer below the - scrollable options, divided by a top border; -- disable the reset action when no filter is active, and close the dropdown - after resetting. - -Do not represent the empty state as a selectable `All` option. The footer reset -action is the single way to return the multi-select to its unfiltered state. - -### Standard table controls - -Dense tables use the shared `x-table.*` components so search, filters, sorting, -and backend loading states remain visually and behaviorally consistent: - -- `` owns the responsive search-left/actions-right layout; -- `` owns the search icon, optional loading indicator, clear - action, sizing, and input anatomy; -- `` owns the static Filter trigger, active-count pill, - multi-select panel, scrollable options area, and Reset filters footer; -- `` owns the static Sort trigger and single-select panel; -- `` overlays only the changing table data for backend search, - filter, sort, and pagination requests. - -Tables continue to own their filter options, sort choices, headers, rows, -queries, permissions, and empty states. Backend-filtered or paginated tables -must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar -and control anatomy but do not show an artificial loading state. - -### Buttons - -- neutral actions use the shared `.button`; -- primary actions use the theme-aware purple/yellow tint; -- destructive actions use the existing error treatment; -- use outline Reicons where a matching glyph exists; -- avoid raw browser-default buttons and old dark-mode purple fills. - -### Unsaved changes - -`resources/views/components/unsaved-bar.blade.php` is a compact floating -bottom-center pill. It contains: - -- “You have changes that haven't been saved yet.” -- a subtle Reset action; -- a theme-aware Save changes button matching the tab accent. - -On small viewports the pill is inset (`inset-x-3`) and stacks: full label on -the first line, Reset / Save on the second (right-aligned). From `sm` up it -returns to the centered single-row nowrap pill. - -Do not restore the old full-width footer. - -Deferred fields in one Livewire component use one floating unsaved bar and one -submit action. Do not add a separate “Save configuration” button to every -card. Selectors that are safe to persist independently should use the existing -instant-save pattern. - ---- - -## 7. Dense tables - -Collections with many rows should use the Cloudflare-inspired table pattern: - -- toolbar above the table; -- search on the left; -- filters, sort, view toggles, and Add on the right; -- 40px header row and roughly 48px data rows; -- subtle row hover; -- plain text or the shared status badge rather than large colored chips; -- compact action at the far right; -- no separate layer card for each item. - -Do not add a summary card above a table when it only repeats the row count, -current page, or refresh interval. Keep counts and pagination in the footer. -Background polling stays silent unless its state is actionable; do not add a -“Live updates” badge just to explain that a table refreshes. Filters only -render meaningful values; use the shared listbox instead of a number input or -browser-native control. - -The footer is always inside the table shell: - -- `Showing X–Y of Z` on the left; -- first, previous, current page, next, and last controls on the right. - -Hide the entire pagination footer when there is only one page (`totalPages > 1`). -A lone “1–2 of 2” bar with disabled controls adds noise and is unnecessary. - -Use `x-status-badge` for resource and execution state. It is a small neutral -pill with a semantic dot, not a full colored rectangle. - -Relevant classes: - -- `.data-table` -- `.data-table-header` -- `.data-table-row` -- `.table-badge` - -Create a page-specific grid class when columns differ. Add responsive rules -that hide secondary columns before allowing horizontal overflow. - ---- - -## 8. Modals, confirmations, and toasts - -### Modals - -`x-modal-input` and confirmation dialogs reuse the layer-card shell: - -- compact elevated header; -- nested base-color body; -- content-width desktop sizing; -- shared 32px controls; -- no redundant description below a self-explanatory title; -- custom listboxes instead of native browser selects; -- right-aligned footer actions below a divider; -- compact action buttons, never a submit button stretched by a column layout. - -Edit modals should use the same field layout and option set as their matching -create modal. - -### Command palette - -The global search command palette (`livewire:global-search`) is a compact -top-anchored overlay: - -- elevated shell with hairline ring and modal shadow (not a heavy floating card); -- recessed-neutral header strip with outline search glyph and 14px input; -- compact OS-aware mod+K (`⌘K` on macOS, `Ctrl+K` on Windows/Linux) / `/` / `ESC` kbd chips matching the sidebar search trigger; -- nested base-color results body with group labels in sentence case; -- dense result rows as inset 6px-radius pills (listbox anatomy), not full-bleed - bars with global focus rings; -- hover uses neutral fill; keyboard focus uses a soft accent wash plus a 2px - left rail — never the global `ring-2` / ring-offset treatment; -- create rows use a neutral plus tile that only picks up the accent when the - row is focused; -- type pills and quickcommand chips stay recessed; they tint with the accent - only on the focused row; -- neutral thin scrollbar inside the results body (not brand-colored); -- create-resource modals opened from the palette reuse the standard - `application-settings-section` layer-card shell. - -Preserve keyboard navigation (arrow keys, Enter via focused links, Escape to -clear then close), `/` and mod+K (⌘K / Ctrl+K by OS) open shortcuts, and the multi-step -server → destination → project → environment create flow. - -### Toasts - -`resources/views/components/toast.blade.php` provides the global -`window.toast(message, options)` API and Livewire event handling. - -Current toast behavior: - -- compact layered card, maximum width 26rem; -- Reicon status tile for success, info, warning, danger, or default; -- title plus optional description; -- dismiss and copy-details actions; -- up to four stacked notifications; -- four-second dismissal, paused while hovered; -- support for all six screen positions and sanitized custom HTML. - -Do not bring back the old oversized dark rectangle. - ---- - -## 9. Terminals, logs, and metrics - -### Terminals - -Application and server browser terminals use the same browser-oriented console -shell, theme picker, compact header controls, and outline `browser-terminal` -Reicon. Hide a container switcher when only one container exists. - -### Logs - -Runtime and deployment logs should feel like a clean terminal surface: - -- keep a single log stream inside one layer card instead of adding an - introductory card above it; -- one compact toolbar; -- a recessed monospace log viewport; -- search and line-count controls aligned with icon actions; -- clear live/follow state; -- fullscreen support without changing the control language; -- custom listbox-style menus instead of browser dropdowns. - -### Metrics - -Metrics pages use separate layer cards for range selection, CPU, and memory. -Charts follow the application metrics implementation: - -- 240px area chart; -- smooth 2px stroke and restrained gradient fill; -- dashed neutral grid; -- no ApexCharts toolbar; -- tooltip positioned at the hovered point; -- UTC on both axes and tooltip; -- 20% headroom above observed values; -- downsample long time ranges before rendering. - -Only add a metric if Sentinel exposes historical data for it. Current Sentinel -history endpoints store CPU and memory. Root filesystem usage is included in -the periodic push payload for threshold notifications, but it is not stored as -a historical Sentinel metric and has no history endpoint, so it cannot power a -disk-usage graph yet. - ---- - -## 10. Current reference surfaces - -Use these as implementation references: - -| Surface | Reference | -|---|---| -| Dashboard overview | `resources/views/livewire/dashboard.blade.php` | -| Top-level collection cards | `resources/views/livewire/project/index.blade.php`, `resources/views/source/all.blade.php` | -| Top-level family tabs | `resources/views/components/team/navbar.blade.php`, `resources/views/components/notification/navbar.blade.php` | -| General settings and form anatomy | `resources/views/livewire/project/application/general.blade.php` | -| Advanced settings | `resources/views/livewire/project/application/advanced.blade.php` | -| Fixed layer-2 resource navigation | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/livewire/server/navbar.blade.php` | -| Grouped settings sidebar | `resources/views/livewire/project/application/configuration.blade.php`, `resources/views/components/server/sidebar.blade.php` | -| Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | -| Standard table toolbar controls | `resources/views/components/table/*` | -| Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | -| Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | -| Layer card | `resources/views/components/application/settings-section.blade.php` | -| Custom dropdown | `resources/views/components/forms/listbox.blade.php` | -| Empty state | `resources/views/components/empty.blade.php` | -| Status pill | `resources/views/components/status-badge.blade.php` | -| Floating save pill | `resources/views/components/unsaved-bar.blade.php` | -| Global toast | `resources/views/components/toast.blade.php` | -| Command palette / global search | `resources/views/livewire/global-search.blade.php` | -| Outline icons | `resources/views/components/reicon.blade.php` | -| Shared styling | `resources/css/app.css`, `resources/css/utilities.css` | -| HTTP error pages | `resources/views/components/error-page.blade.php`, `resources/views/errors/*` | - -Already restyled application configuration surfaces include General, Advanced, -Environment Variables, Persistent Storage, Servers, Scheduled Tasks, Webhooks, -Preview Deployments, Healthcheck, Rollback, Resource Limits, Resource -Operations, Metrics, Tags, and Danger Zone. - -HTTP error pages (400, 401, 402, 403, 404, 419, 429, 500, 503) use the shared -`` component on the public auth-style canvas: theme-aware status -code, compact title and muted description, neutral `.button` actions, and an -`auth-text-link`-style Contact support link. Keep copy sentence-case and avoid -oversized 200px status numbers. - ---- - -## 11. Restyling checklist - -1. Inventory every route and reusable partial in the family before editing. -2. Read the current Blade and Livewire class before changing presentation. -3. Preserve every existing action, authorization check, loading state, and - confirmation. -4. Add the correct dual navigation and scoped workspace/form class. -5. Convert meaningful groups to layer cards and use `gap-6`. -6. Make the responsive column count match the controls visible in every state. -7. Replace native selects and checkbox-style configuration with listboxes. -8. Use one save model per component: instant-save or one floating dirty bar. -9. Check nested radii using `outer = inner + inset`. -10. Keep modal descriptions purposeful and footer actions compact/right-aligned. -11. Use tables for dense collections and cards for forms or summaries. -12. Use `x-status-badge`, `x-empty`, and `x-reicon`. -13. Confirm light and dark accent behavior. -14. Check fixed-nav anchor offsets and responsive stacking. -15. Sweep every sibling route for legacy controls and shells. -16. Run `git diff --check`. -17. Compile Blade views in the `coolify` container. -18. Build assets in `coolify-vite`. -19. Hard-refresh and inspect the family routes in both themes. diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index eb2d68c82..b44481e84 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -130,6 +130,7 @@ class Domains extends Component $application->setNoindexDomains($domains); $application->save(); $this->service->parse(); + $this->refreshDomains(); $this->dispatch('configurationChanged')->to(ConfigurationChecker::class); $this->dispatch('success', 'Search engine indexing updated.'); } diff --git a/resources/css/app.css b/resources/css/app.css index ad3412bf2..16713718d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2109,11 +2109,15 @@ input[type="search"]::-webkit-search-results-decoration { } .domains-table-grid { - grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; + grid-template-columns: minmax(0, 1.8fr) 8.5rem 10rem 11rem 6.5rem; +} + +.domains-table-grid-service { + grid-template-columns: minmax(0, 1.8fr) 8.5rem 10rem 6.5rem; } .domains-table-grid-compose { - grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; + grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem 10rem 11rem 6.5rem; } .domains-mobile-label { @@ -2127,9 +2131,9 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.75rem; } - /* Hide "Last checked" (3rd of 4) */ - .domains-table-grid > :nth-child(3) { - display: none; + .domains-table-grid-service { + grid-template-columns: minmax(0, 1fr) 8.25rem 9rem 5.5rem; + gap: 0.75rem; } .domains-table-grid-compose { @@ -2137,20 +2141,21 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.75rem; } - /* Hide "Service" (2) and "Last checked" (4) of 5 */ - .domains-table-grid-compose > :nth-child(2), - .domains-table-grid-compose > :nth-child(4) { + /* Hide "Service" */ + .domains-table-grid-compose > :nth-child(2) { display: none; } } @media (max-width: 768px) { .data-table-header.domains-table-grid, + .data-table-header.domains-table-grid-service, .data-table-header.domains-table-grid-compose { display: none; } .data-table-row.domains-table-grid, + .data-table-row.domains-table-grid-service, .data-table-row.domains-table-grid-compose { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2172,26 +2177,28 @@ input[type="search"]::-webkit-search-results-decoration { 'indexing indexing'; } - .data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(5), - .data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(6) { + .data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(4), + .data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(5) { display: none; } /* Domain cell */ .data-table-row.domains-table-grid > :nth-child(1), + .data-table-row.domains-table-grid-service > :nth-child(1), .data-table-row.domains-table-grid-compose > :nth-child(1) { grid-area: domain; min-width: 0; } .data-table-row.domains-table-grid > :nth-child(1) a, + .data-table-row.domains-table-grid-service > :nth-child(1) a, .data-table-row.domains-table-grid-compose > :nth-child(1) a { white-space: normal; overflow-wrap: anywhere; word-break: break-word; } - /* Non-compose: 1 Domain, 2 DNS, 3 Last checked, 4 Indexing, 5 Direction, 6 Actions */ + /* Non-compose: 1 Domain, 2 DNS, 3 Indexing, 4 Direction, 5 Actions */ .data-table-row.domains-table-grid > :nth-child(2) { grid-area: meta; display: flex !important; @@ -2201,23 +2208,37 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid > :nth-child(3) { - display: none !important; - } - - .data-table-row.domains-table-grid > :nth-child(4) { grid-area: indexing; } - .data-table-row.domains-table-grid > :nth-child(5) { + .data-table-row.domains-table-grid > :nth-child(4) { grid-area: direction; } - .data-table-row.domains-table-grid > :nth-child(6) { + .data-table-row.domains-table-grid > :nth-child(5) { grid-area: actions; align-self: center; } - /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Last checked, 5 Indexing, 6 Direction, 7 Actions */ + /* Compose service group: 1 Domain, 2 DNS, 3 Indexing, 4 Actions */ + .data-table-row.domains-table-grid-service > :nth-child(2) { + grid-area: meta; + display: flex !important; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + } + + .data-table-row.domains-table-grid-service > :nth-child(3) { + grid-area: indexing; + } + + .data-table-row.domains-table-grid-service > :nth-child(4) { + grid-area: actions; + align-self: center; + } + + /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Indexing, 5 Direction, 6 Actions */ .data-table-row.domains-table-grid-compose > :nth-child(2) { display: none !important; } @@ -2231,18 +2252,14 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid-compose > :nth-child(4) { - display: none !important; - } - - .data-table-row.domains-table-grid-compose > :nth-child(5) { grid-area: indexing; } - .data-table-row.domains-table-grid-compose > :nth-child(6) { + .data-table-row.domains-table-grid-compose > :nth-child(5) { grid-area: direction; } - .data-table-row.domains-table-grid-compose > :nth-child(7) { + .data-table-row.domains-table-grid-compose > :nth-child(6) { grid-area: actions; align-self: center; } diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php deleted file mode 100644 index f01ba0e73..000000000 --- a/resources/views/components/applications/advanced.blade.php +++ /dev/null @@ -1,26 +0,0 @@ -
- - - -
diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 531d32660..b61396a0d 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -15,6 +15,7 @@ 'disabled' => false, 'tooltip' => true, 'portal' => false, + 'preserveValue' => false, ]) @php @@ -45,22 +46,33 @@
whereStartsWith('x-model') }} + }" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }" + {{ $attributes->whereStartsWith('x-model') }} {{ $attributes->whereStartsWith('x-effect') }} + @if ($preserveValue) wire:ignore @endif @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"> - - -
diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index c8e778b07..b1a35cb21 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -202,20 +202,38 @@ wire:key="application-compose-domain-group-{{ $redirectWireKey }}" x-show="matchesDomainSearch(@js($serviceName.' '.$rows->pluck('url')->implode(' ')))" class="border-b border-neutral-200 last:border-b-0 dark:border-white/10"> -
+
{{ $serviceName }} +
+ + @if (auth()->user()?->can('update', $application) && ! $labelsAreWritable) + + @else + + {{ match ($serviceRedirects[$redirectWireKey] ?? 'both') { + 'www' => 'Redirect to www', + 'non-www' => 'Redirect to non-www', + default => 'Allow both', + } }} + + @endif +
-
+
Domain - DNS - Last checked + DNS Check Search engine indexing - Direction
@foreach ($rows as $row) @@ -232,7 +250,8 @@ 'application' => $application, 'labelsAreWritable' => $labelsAreWritable, 'isCompose' => false, - 'domainDirection' => $serviceRedirects[$redirectWireKey] ?? 'both', + 'showDirectionControl' => false, + 'domainGridClass' => 'domains-table-grid-service', ]) @endforeach
@@ -250,7 +269,6 @@
Domain DNS Check - Last checked Search engine indexing Direction diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index be825792f..8b7f43c0d 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -104,13 +104,13 @@ @else @endcan @@ -183,30 +183,28 @@ @if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)) Load a Compose file to deploy. @else - @if (!$application->destination->server->isSwarm()) - - @endif
- @if (str($application->status)->startsWith('exited')) - - - Deploy - - @else -
+ @endif + @if (!$application->destination->server->isSwarm()) + + + @endif
- @endif +
@endif
diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index 03e5ded07..d5baaee56 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -13,10 +13,7 @@ 'pending' => 'DNS pending', default => 'DNS unknown', }; - $checkedAt = ! empty($row['checked_at']) - ? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans() - : null; - $gridClass = ($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid'; + $gridClass = $domainGridClass ?? (($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid'); $domainParts = $isSuggested ? null : parse_url($row['url']); $faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host']) ? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico' @@ -38,7 +35,7 @@ ->filter(fn ($item) => $redirectPairKey($item['url']) === $pairKey) ->keys() ->first(); - $showDirection = ! $isSuggested && $firstPairRowIndex === $index; + $showDirection = ($showDirectionControl ?? true) && ! $isSuggested && $firstPairRowIndex === $index; @endphp
@else @if ($faviconUrl) - + @endif {{ $row['url'] }} @@ -101,11 +105,6 @@ @endif
-
- {{ $checkedAt ?: '-' }} -
-
@unless ($isSuggested) Search engine indexing @@ -114,6 +113,7 @@ - @elseif (auth()->user()?->can('update', $application) && ! $labelsAreWritable) @php $rowDirection = $domainDirection ?? $redirect; @@ -140,6 +141,7 @@ @endif @if ($showDirection && auth()->user()?->can('update', $application) && ! $labelsAreWritable) {{ $directionLabel }} @endif
+ @endif
@can('update', $application) diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index 6ab8ff28a..38e81f278 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -107,13 +107,13 @@ @else @endcan @@ -181,43 +181,70 @@ class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
@if ($service->isDeployable) -
- @if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded')) -
-
- @else - - - - Deploy - - @endif +
@else @@ -264,7 +291,7 @@ if (isDeploymentProgress) { $wire.$dispatch('error', - 'There is a deployment in progress.

You can force deploy in the Advanced section.'); + 'There is a deployment in progress.

You can force deploy from the Actions menu.'); return; } @@ -278,7 +305,7 @@ if (isDeploymentProgress) { $wire.$dispatch('error', - 'There is a deployment in progress.

You can force deploy in the Advanced section.'); + 'There is a deployment in progress.

You can force deploy from the Actions menu.'); return; } diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 1717ff4ce..c0de5b62d 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -11,8 +11,7 @@ @if ($showServiceColumn) Service @endif - DNS - Last checked + DNS Check Search engine indexing Direction @@ -39,9 +38,6 @@ 'pending' => 'DNS pending', default => 'DNS unknown', }; - $checkedAt = ! empty($row['checked_at']) - ? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans() - : null; $serviceLabel = filled($row['service_name'] ?? null) ? \Illuminate\Support\Str::headline($row['service_name']) : '-'; @@ -84,12 +80,19 @@ @else @if ($faviconUrl) - + @endif
{{ $row['url'] }} @@ -123,10 +126,6 @@ @endif
-
- {{ $checkedAt ?: '-' }} -
-
@unless ($isSuggested) Search engine indexing @@ -136,6 +135,7 @@ @elseif (auth()->user()?->can('update', $service)) - + + + Restart Proxy @if ($traefikDashboardAvailable) - + + + Traefik Dashboard @endif @else @endif
@@ -276,55 +286,65 @@ @if ($server->proxySet()) @can('manageProxy', $server) -
- @if ($proxyCanBeStopped) -
- -
- @if ($traefikDashboardAvailable) - - Traefik Dashboard - - +
+ + +
@endcan @endif diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 309e69b9c..b2d5658ab 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -108,12 +108,39 @@ it('lists existing domains as individual rows', function () { ->assertSee('https://example.com') ->assertSee('https://www.example.com') ->assertSee('https://example.com/favicon.ico', false) + ->assertSee('class="relative size-4 shrink-0"', false) + ->assertSee('domain-favicon-fallback', false) + ->assertSee('class="invisible absolute inset-0 size-4 rounded-sm"', false) + ->assertSee('$el.previousElementSibling.classList.add(\'hidden\')', false) ->assertSee('x-on:error="$el.remove()"', false) + ->assertSee('class="min-w-0 flex-1 text-[13px]', false) ->html(); expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2); }); +it('shows one redirect direction control in each compose service header', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'api' => [ + 'domain' => 'https://api.example.com,https://www.api.example.com', + 'redirect' => 'www', + ], + ]), + ]); + + $html = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSuccessful() + ->assertSee('api') + ->html(); + + expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1) + ->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0) + ->and(substr_count($html, 'domain-direction-service-api'))->toBeGreaterThan(0); +}); + it('shows dns entries control next to Add', function () { Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSuccessful() @@ -1225,15 +1252,25 @@ it('uses the compact service domains layout for compose applications', function ->toContain('application-compose-domain-group-{{ $redirectWireKey }}') ->toContain('class="application-settings-section-body mt-1 scroll-mt-28') ->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]') - ->toContain('class="data-table-header domains-table-grid"') + ->toContain('class="data-table-header domains-table-grid-service"') ->toContain('Direction') ->toContain('Search engine indexing') + ->not->toContain('Last checked') ->not->toContain('id="edit-domain-direction"') - ->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"') - ->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"') + ->toContain('id="domain-direction-service-{{ $redirectWireKey }}"') + ->toContain('onChange="updateServiceRedirect"') + ->toContain("'showDirectionControl' => false") ->not->toContain('title="No domains for this service"'); }); +it('does not render a last checked column in the domains table', function () { + $view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php')); + $row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); + + expect($view)->not->toContain('Last checked') + ->and($row)->not->toContain('$checkedAt'); +}); + it('uses compact labeled domain cards on mobile', function () { $styles = file_get_contents(resource_path('css/app.css')); $row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); @@ -1407,6 +1444,7 @@ it('updates search engine indexing from the domains view', function () { ->assertSee('Direction') ->assertSee('toggleNoindexDomain', false) ->assertSee('updateRedirect', false) + ->assertSee('wire:ignore', false) ->assertDontSee('x-model="localIndexing"', false) ->assertDontSee('x-model="localDirection"', false) ->assertDontSee('@js(', false) diff --git a/tests/Feature/ListboxTriggerTruncationTest.php b/tests/Feature/ListboxTriggerTruncationTest.php index 1e9a46a19..666283edb 100644 --- a/tests/Feature/ListboxTriggerTruncationTest.php +++ b/tests/Feature/ListboxTriggerTruncationTest.php @@ -88,6 +88,31 @@ test('listbox forwards dynamic disabled state to its trigger', function () { ->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"'); }); +test('listbox waits for change handlers and prevents overlapping selections', function () { + $listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php')); + + expect($listbox) + ->toContain('saving: false') + ->toContain('async choose(option)') + ->toContain('await this.$wire.') + ->toContain('if (this.saving || option.disabled) return;') + ->toContain("'pointer-events-none opacity-70': saving"); +}); + +test('listbox does not send a second live entangle request when using a change handler', function () { + $listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php')); + + expect($listbox)->toContain('@elseif ($live && ! $onChange) @entangle($id).live'); +}); + +test('listbox can preserve its client value across Livewire morphs', function () { + $listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php')); + + expect($listbox) + ->toContain("'preserveValue' => false") + ->toContain('@if ($preserveValue) wire:ignore @endif'); +}); + test('notification event multiselect truncates long selected summaries', function () { $html = Blade::render(<<<'BLADE' \$sidebar"); }); -it('keeps application links next to advanced actions on the right', function () { +it('keeps advanced operations in a separated section at the bottom of actions menus', function () { $application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); + $service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $links = file_get_contents(resource_path('views/components/applications/links.blade.php')); - $desktop = str($application)->after('resource-heading-actions flex')->toString(); - $advancedPosition = strpos($desktop, 'after('resource-heading-actions flex')->toString(); + $serviceDesktop = str($service)->after('resource-heading-actions flex')->toString(); - expect($advancedPosition)->not->toBeFalse() - ->and($linksPosition)->not->toBeFalse() - ->and($linksPosition)->toBeGreaterThan($advancedPosition) + expect($applicationDesktop) + ->not->toContain('toContain('application-desktop-actions') + ->toContain('role="separator"') + ->toContain('Force deploy without cache') + ->and($serviceDesktop) + ->not->toContain('toContain('service-desktop-actions') + ->toContain('role="separator"') + ->toContain('Pull Latest Images & Restart') + ->toContain('Force Restart') + ->toContain('Force Deploy') + ->toContain('Force Cleanup Containers') ->and($links)->toContain("'right-0! left-auto! min-w-60! max-w-96!' => !\$fullWidth") ->and($links)->toContain('listbox-option justify-start! gap-2.5!') ->and($links)->not->toContain('md:left-0 md:right-auto'); @@ -146,15 +156,15 @@ it('groups application lifecycle controls in an actions dropdown', function () { ->toContain('Deploy'); }); -it('shows deploy directly when it is the only available lifecycle action', function () { +it('keeps deploy in the actions menu alongside advanced operations', function () { $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); $desktop = str($heading)->after('resource-heading-actions flex')->toString(); expect($desktop) ->toContain("@if (str(\$application->status)->startsWith('exited'))") - ->toContain('id="application-desktop-deploy"') - ->toContain('@else') - ->toContain('id="application-desktop-actions"'); + ->toContain('id="application-desktop-actions"') + ->toContain('Deploy') + ->toContain('Force deploy without cache'); }); it('moves application backups from the top tabs into the settings sidebar', function () { @@ -229,7 +239,24 @@ it('uses neutral icons for non-destructive resource actions', function () { ->not->toContain('class="size-3.5 text-orange-500') ->not->toContain('class="size-3.5 text-warning"') ->not->toContain('class="size-4 text-warning"') - ->toContain('name="stop" class="size-3.5 text-error"'); + ->toContain('name="stop-circle"'); + } +}); + +it('uses a circular stop icon in application and service action menus', function () { + $icons = file_get_contents(resource_path('views/components/reicon.blade.php')); + + expect($icons) + ->toContain("'stop-circle' =>") + ->toContain('toContain('toContain('name="stop-circle" class="size-3.5 text-error"') + ->not->toContain('name="stop" class="size-3.5 text-error"'); } }); diff --git a/tests/Feature/SearchableListboxComponentTest.php b/tests/Feature/SearchableListboxComponentTest.php index 33b67bc72..a0f66d422 100644 --- a/tests/Feature/SearchableListboxComponentTest.php +++ b/tests/Feature/SearchableListboxComponentTest.php @@ -52,3 +52,15 @@ test('searchable listbox keeps the helper outside the label association', functi ->toContain('aria-label="More information"') ->not->toMatch('/]*for="tz-trigger"[^>]*>[\s\S]*aria-label="More information"[\s\S]*<\/label>/'); }); + +test('searchable listbox serializes change handlers', function () { + $listbox = file_get_contents(resource_path('views/components/forms/searchable-listbox.blade.php')); + + expect($listbox) + ->toContain('saving: false') + ->toContain('async choose(option)') + ->toContain('if (this.saving || option.disabled)') + ->toContain('await this.$wire.') + ->toContain("'pointer-events-none opacity-70': saving") + ->toContain('@elseif ($live && ! $onChange) @entangle($id).live'); +}); diff --git a/tests/Feature/ServerNavbarStatusLayoutTest.php b/tests/Feature/ServerNavbarStatusLayoutTest.php index de55e1290..11b5b0b80 100644 --- a/tests/Feature/ServerNavbarStatusLayoutTest.php +++ b/tests/Feature/ServerNavbarStatusLayoutTest.php @@ -41,3 +41,20 @@ it('uses the branded input focus state for the server filter', function () { ->toContain('not->toContain('after('id="server-desktop-actions"')->before('@endteleport')->toString(); + + expect($desktopActions) + ->toContain('Actions') + ->toContain('Traefik Dashboard') + ->toContain('name="external-link" class="size-3! opacity-70"') + ->toContain('class="flex size-4 shrink-0 items-center justify-center"') + ->toContain('Restart Proxy') + ->toContain('Stop Proxy') + ->toContain('Start Proxy') + ->toContain('Refresh Proxy Status') + ->toContain('listbox-panel') + ->not->toContain('toContain("id=\"service-domain-direction-{$this->apiApp->id}-0-trigger\"") ->toContain("id=\"service-domain-indexing-{$this->apiApp->id}-0-trigger\"") ->toContain('src="https://api.example.com/favicon.ico"') + ->toContain('class="relative size-4 shrink-0"') + ->toContain('domain-favicon-fallback') + ->toContain('class="invisible absolute inset-0 size-4 rounded-sm"') + ->toContain('$el.previousElementSibling.classList.add(\'hidden\')') ->toContain('x-on:error="$el.remove()"') + ->toContain('class="min-w-0 flex-1 text-[13px]') ->toContain('class="listbox-trigger"') ->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible') ->toContain('dark:bg-white/[0.04]') ->toContain('Domain') - ->toContain('DNS') - ->toContain('Last checked') + ->toContain('DNS Check') + ->not->toContain('Last checked') ->not->toContain("service-domain-group-{$this->webApp->id}") ->and(substr_count($html, '2 domains'))->toBe(1) ->and(strpos($html, '>API'))->toBeLessThan(strpos($html, 'Domain')) @@ -556,12 +561,16 @@ it('updates search engine indexing from the service domains view', function () { ->assertSee('Direction') ->assertSee('toggleNoindexDomain', false) ->assertSee('updateServiceRedirect', false) + ->assertSee('wire:ignore', false) ->assertDontSee('x-model="localIndexing"', false) ->assertDontSee('x-model="localDirection"', false) ->assertDontSee('@js(', false) ->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex') ->assertDispatched('configurationChanged') - ->assertDispatched('success'); + ->assertDispatched('success') + ->assertSet('service', fn (Service $service): bool => $service->applications + ->firstWhere('id', $this->apiApp->id) + ?->isDomainNoindexed('https://api.example.com') === true); expect($this->apiApp->refresh()->noindexDomains()->all()) ->toBe(['https://api.example.com']); From 1a8a4bb5ef98c217f09777142162aaa0b5462fb7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:08:44 +0200 Subject: [PATCH 03/10] fix(ui): refine overlays, toast actions, and save-state feedback Improve overlay stacking and positioning, add toast copying, standardize domain labels, and animate unsaved changes while saving. --- resources/css/utilities.css | 2 +- .../components/forms/domain-input.blade.php | 20 ++++++++----- resources/views/components/helper.blade.php | 4 +-- .../views/components/icon-tooltip.blade.php | 2 +- resources/views/components/navbar.blade.php | 2 +- .../views/components/popup-small.blade.php | 28 +++++++++++-------- resources/views/components/toast.blade.php | 25 +++++++++++++---- .../views/components/unsaved-bar.blade.php | 4 +-- .../shared/configuration-checker.blade.php | 2 +- tests/Feature/ApplicationDomainsTest.php | 4 +++ .../Livewire/ConfigurationCheckerTest.php | 8 ++++-- tests/Feature/PreviewStatusSummaryTest.php | 1 + tests/Feature/SentinelUnsavedBarFlashTest.php | 27 +++++++++++++++++- tests/Feature/ToastPositionTest.php | 26 +++++++++++++++++ tests/Feature/TooltipStackingTest.php | 14 ++++++++++ 15 files changed, 133 insertions(+), 36 deletions(-) create mode 100644 tests/Feature/ToastPositionTest.php create mode 100644 tests/Feature/TooltipStackingTest.php diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 26f4cc4ce..668cb755b 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -163,7 +163,7 @@ } @utility auth-tooltip { - @apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10; + @apply fixed z-[10000] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10; } @utility alert-success { diff --git a/resources/views/components/forms/domain-input.blade.php b/resources/views/components/forms/domain-input.blade.php index 482788906..030fc0f5f 100644 --- a/resources/views/components/forms/domain-input.blade.php +++ b/resources/views/components/forms/domain-input.blade.php @@ -43,15 +43,17 @@
- +
+ +
@error($errorId ?? $id) @@ -60,13 +62,17 @@
- +
+ +
- +
+ +

diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 6b90b5929..f935e7d47 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -94,7 +94,7 @@ } }" @pointerdown.window="closeWhenPointerIsOutside($event)" @keydown.window.escape="close" @resize.window="open && position()" @scroll.window="open && position()" - {{ $attributes->merge(['class' => 'relative inline-block align-middle']) }}> + {{ $attributes->merge(['class' => 'relative inline-flex align-middle']) }}> {{-- button (not div) so label-for associations do not steal the click on mobile --}}

diff --git a/resources/views/components/popup-small.blade.php b/resources/views/components/popup-small.blade.php index fca570f34..45f12eb6e 100644 --- a/resources/views/components/popup-small.blade.php +++ b/resources/views/components/popup-small.blade.php @@ -4,6 +4,7 @@ 'compactAfter' => null, 'compactStorageKey' => null, 'compactStoragePrefix' => null, + 'position' => 'bottom-right', ])
- + class="fixed right-4 z-999 {{ $position === 'top-right' ? 'top-16' : 'bottom-4' }}"> + -
+
+
diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 533464815..8c3eef13e 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -6,7 +6,7 @@ type: options.type ?? 'default', message, description: options.description ?? '', - position: options.position ?? 'top-center', + position: options.position ?? 'bottom-right', html: options.html ?? '', }, })); @@ -17,9 +17,9 @@