feat(ui): dock configuration warnings in the navigation HUD

This commit is contained in:
Andras Bacsai
2026-08-12 16:24:01 +02:00
parent feb5be3a3c
commit 8e6e2b099a
12 changed files with 130 additions and 45 deletions
@@ -13,7 +13,8 @@
$linkItemClasses = 'listbox-option justify-start! gap-2.5!';
@endphp
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }" @keydown.escape.window="open = false">
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Open application links"
@class([
'app-tab shrink-0 gap-1' => !$fullWidth,
@@ -0,0 +1,41 @@
@props(['diff' => []])
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" aria-label="Configuration changes not applied" aria-haspopup="dialog"
:aria-expanded="open" @click="open = !open"
class="flex h-8 items-center justify-center gap-1.5 rounded-lg px-2 text-amber-700 transition-colors hover:bg-amber-100 dark:text-warning dark:hover:bg-warning/10">
<x-reicon name="alert-triangle" class="size-4" />
<span class="hidden text-xs font-medium lg:inline">Changes pending</span>
</button>
<div x-show="open" x-cloak x-transition.opacity role="dialog"
class="fixed top-14 left-1/2 z-[1100] w-[calc(100vw-2rem)] max-w-sm -translate-x-1/2 rounded-lg p-3 lg:absolute lg:top-full lg:right-0 lg:left-auto lg:mt-2 lg:translate-x-0"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
<div class="flex items-start gap-2.5">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
<x-reicon name="alert-triangle" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold leading-4 text-neutral-950 dark:text-fg">
The latest configuration has not been applied
</p>
<p class="mt-0.5 text-[11px] leading-4 text-neutral-600 dark:text-fg-dim">
@if (data_get($diff, 'count'))
{{ data_get($diff, 'count') }}
{{ data_get($diff, 'count') === 1 ? 'change' : 'changes' }} unapplied.
{{ data_get($diff, 'requires_build') ? 'Rebuild required.' : 'Redeploy to apply.' }}
<button type="button"
class="ml-0.5 inline-flex items-center gap-0.5 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80"
@click="open = false; $dispatch('open-configuration-diff')">
View changes
<x-reicon name="arrow-right" class="size-2.5" />
</button>
@else
Redeploy to apply.
@endif
</p>
</div>
</div>
</div>
</div>
@@ -67,7 +67,7 @@
<template x-if="!iconOnly">
<div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
:class="compact
? 'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'
? 'w-[calc(100vw-2rem)] max-w-sm cursor-pointer'
: 'w-[calc(100vw-2rem)] max-w-sm'"
@click="restore()"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
@@ -2,7 +2,8 @@
$linkItemClasses = 'listbox-option justify-start! gap-2.5!';
@endphp
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }" @keydown.escape.window="open = false">
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Open service links"
@class([
'app-tab shrink-0 gap-1' => !$fullWidth,
+3 -1
View File
@@ -60,13 +60,14 @@
</div>
{{-- Dev Server-Timing HUD docks here (local only; empty in production) --}}
<div id="server-timing-hud-slot" data-server-timing-hud-slot class="hidden shrink-0 items-center"></div>
<div id="configuration-warning-hud-slot" class="relative shrink-0"></div>
{{-- Resource actions dock here on desktop. --}}
<div id="resource-action-hud-slot" class="hidden shrink-0 items-center xl:flex"></div>
</div>
</header>
{{-- ============ MOBILE SLIDE-OVER SIDEBAR ============ --}}
<div class="relative z-50 lg:hidden" :class="open ? 'block' : 'hidden'" role="dialog" aria-modal="true">
<div class="relative z-[1000] lg:hidden" :class="open ? 'block' : 'hidden'" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-black/80" x-on:click="open = false"></div>
<div class="fixed inset-y-0 right-0 flex h-full">
<div
@@ -111,6 +112,7 @@
{{-- Dev Server-Timing HUD docks here on <lg (desktop uses #server-timing-hud-slot) --}}
<div id="server-timing-hud-slot-mobile" data-server-timing-hud-slot
class="hidden shrink-0 items-center"></div>
<div id="configuration-warning-hud-slot-mobile" class="relative shrink-0"></div>
<x-top-user-menu />
<button type="button" class="-m-1 p-2 text-neutral-500 dark:text-fg-dim" x-on:click="open = !open">
<span class="sr-only">Open sidebar</span>
@@ -187,6 +187,7 @@
<x-applications.links :application="$application" />
</div>
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu">
@@ -140,6 +140,7 @@
@if ($database->destination->server->isFunctional())
@if (! $databaseStatus->startsWith('exited'))
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
<x-reicon name="play-circle" class="size-3.5 text-warning" />
@@ -29,45 +29,24 @@
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited())
@php
$compactStoragePrefix = "configuration-warning:{$resource->uuid}:";
$currentConfigurationHash = $resource instanceof \App\Models\Application
? $resource->deploymentConfigurationHash()
: md5((string) $resource->config_hash);
$compactStorageKey = $compactStoragePrefix.$currentConfigurationHash;
@endphp
<div wire:key="configuration-warning-{{ $currentConfigurationHash }}"
x-data="{ configurationDiffModalOpen: false, expandedRows: {} }">
<x-popup-small position="top-right" :compact-after="5000" :compact-storage-key="$compactStorageKey"
:compact-storage-prefix="$compactStoragePrefix">
<x-slot:title>
The latest configuration has not been applied
</x-slot:title>
<x-slot:icon>
<x-reicon name="alert-triangle" class="size-4" />
</x-slot:icon>
<x-slot:description>
<span>
@if (data_get($configurationDiff, 'count'))
{{ data_get($configurationDiff, 'count') }}
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }}
unapplied.
@if (data_get($configurationDiff, 'requires_build'))
Rebuild required.
@else
Redeploy to apply.
@endif
<button type="button"
class="ml-0.5 inline-flex items-center gap-0.5 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80"
x-on:click="configurationDiffModalOpen = true">
View changes
<x-reicon name="arrow-right" class="size-2.5" />
</button>
@else
Redeploy to apply.
@endif
</span>
</x-slot:description>
</x-popup-small>
x-data="{ configurationDiffModalOpen: false, expandedRows: {} }"
@open-configuration-diff.window="configurationDiffModalOpen = true">
@teleport('#configuration-warning-hud-slot')
<div>
<x-configuration-warning :diff="$configurationDiff" />
</div>
@endteleport
@teleport('#configuration-warning-hud-slot-mobile')
<div>
<x-configuration-warning :diff="$configurationDiff" />
</div>
@endteleport
@if (data_get($configurationDiff, 'count'))
<template x-teleport="body">
@@ -287,7 +287,8 @@
@if ($server->proxySet())
@can('manageProxy', $server)
<div id="server-desktop-actions" class="resource-heading-actions relative shrink-0"
x-data="{ open: false }" @click.outside="open = false"
x-data="{ open: false }" x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu" wire:loading.attr="disabled" wire:loading.class="is-loading"
@@ -102,7 +102,7 @@ it('supports timed compact popup notifications', function () {
->not->toContain('<div x-show="!iconOnly"')
->not->toContain(':class="iconOnly')
->toContain('x-show="!compact"')
->toContain("'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'");
->toContain("'w-[calc(100vw-2rem)] max-w-sm cursor-pointer'");
});
it('warns when a service has missing required environment variables', function () {
@@ -156,16 +156,26 @@ it('groups application lifecycle controls in an actions dropdown', function () {
->toContain('Deploy');
});
it('raises the desktop top bar while the service actions dropdown is open', function () {
it('raises the desktop top bar while any resource dropdown is open', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$dropdowns = [
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/components/applications/links.blade.php'),
resource_path('views/components/services/links.blade.php'),
];
expect($layout)
->toContain('resourceActionsOpen: false')
->toContain("'z-[1000]': resourceActionsOpen")
->toContain('@resource-actions-toggled.window="resourceActionsOpen = $event.detail.open"')
->and($heading)
->toContain("\$dispatch('resource-actions-toggled', { open })");
->toContain('@resource-actions-toggled.window="resourceActionsOpen = $event.detail.open"');
foreach ($dropdowns as $dropdown) {
expect(file_get_contents($dropdown))
->toContain("\$dispatch('resource-actions-toggled', { open })");
}
});
it('keeps deploy in the actions menu alongside advanced operations', function () {
@@ -179,6 +189,28 @@ it('keeps deploy in the actions menu alongside advanced operations', function ()
->toContain('Force deploy without cache');
});
it('renders configuration warnings as navbar popovers instead of floating notifications', function () {
$checker = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php'));
$warning = file_get_contents(resource_path('views/components/configuration-warning.blade.php'));
expect($checker)
->toContain("@teleport('#configuration-warning-hud-slot')")
->toContain("@teleport('#configuration-warning-hud-slot-mobile')")
->toContain('<x-configuration-warning :diff="$configurationDiff" />')
->not->toContain('<x-popup-small position="top-right"')
->and($warning)
->toContain('aria-label="Configuration changes not applied"')
->toContain('<span class="hidden text-xs font-medium lg:inline">Changes pending</span>')
->toContain('The latest configuration has not been applied')
->toContain('fixed top-14 left-1/2')
->toContain('-translate-x-1/2')
->toContain('lg:absolute lg:top-full lg:right-0 lg:left-auto')
->toContain('lg:translate-x-0')
->toContain('@click.outside="open = false"')
->toContain('@keydown.escape.window="open = false"')
->toContain("\$dispatch('open-configuration-diff')");
});
it('moves application backups from the top tabs into the settings sidebar', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
@@ -63,6 +63,32 @@ it('separates the mobile sidebar from the page with a visible border', function
expect($layout)->toContain('max-w-56 min-w-0 flex-col border-l border-neutral-200 bg-white shadow-xl dark:border-white/[0.12] dark:bg-panel');
});
it('keeps the mobile navbar above floating configuration warnings', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$popup = file_get_contents(resource_path('views/components/popup-small.blade.php'));
expect($layout)
->toContain('class="relative z-[1000] lg:hidden"')
->and($popup)->toContain('z-999');
});
it('keeps compact configuration warnings the same width as their expanded state', function () {
$popup = file_get_contents(resource_path('views/components/popup-small.blade.php'));
expect($popup)
->toContain("? 'w-[calc(100vw-2rem)] max-w-sm cursor-pointer'")
->toContain(": 'w-[calc(100vw-2rem)] max-w-sm'")
->not->toContain('sm:w-auto');
});
it('provides configuration warning slots in both desktop and mobile navbars', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('id="configuration-warning-hud-slot"')
->toContain('id="configuration-warning-hud-slot-mobile"');
});
it('shows the full team name in the header', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$switcher = file_get_contents(resource_path('views/livewire/switch-team.blade.php'));