fix(ui): move deployments indicator into the sidebar

The floating bottom-left pill and its expanded list covered page content.

- Desktop: show a compact 'N deployments' row in the sticky sidebar footer
  (spinner with count badge when the sidebar is collapsed); the list opens
  to the right of the sidebar
- Mobile: show a spinner with the count in the top bar; the list opens
  below the top bar
- Poll only the copy visible at the current breakpoint (wire:poll.visible)
- Show the indicator on the dashboard too, and drop the path-based
  visibility logic and its navigate round trip
This commit is contained in:
Andras Bacsai
2026-09-29 09:58:54 +02:00
parent af983a29d8
commit 0393d4e931
5 changed files with 133 additions and 163 deletions
+2 -36
View File
@@ -9,23 +9,10 @@ use Livewire\Component;
class DeploymentsIndicator extends Component
{
public bool $expanded = false;
/**
* Persisted across polls. Livewire update requests are not the page route,
* so this must not be re-derived from request()->routeIs() on every render.
* Where the indicator is rendered: 'sidebar' (desktop sidebar footer) or 'mobile' (mobile top bar).
*/
public bool $shouldShow = true;
public function mount(): void
{
$this->shouldShow = $this->shouldShowForCurrentRequest();
}
public function updateShouldShowFromPath(string $path): void
{
$this->shouldShow = ! $this->isDashboardPath($path);
}
public string $variant = 'sidebar';
#[Computed]
public function deployments()
@@ -54,29 +41,8 @@ class DeploymentsIndicator extends Component
return $this->deployments->count();
}
public function toggleExpanded()
{
$this->expanded = ! $this->expanded;
}
public function render()
{
return view('livewire.deployments-indicator');
}
private function shouldShowForCurrentRequest(): bool
{
if (request()->routeIs('dashboard')) {
return false;
}
return ! $this->isDashboardPath(request()->path());
}
private function isDashboardPath(string $path): bool
{
$normalized = trim($path, '/');
return $normalized === '' || $normalized === '/';
}
}
+17 -11
View File
@@ -1,3 +1,5 @@
@props(['deploymentsIndicator' => false])
<nav class="flex flex-col flex-1 bg-white border-r border-neutral-200 dark:border-white/[0.06] dark:bg-panel pt-2"
:class="collapsed ? 'px-2 lg:px-3 sidebar-collapsed' : 'px-2 lg:px-3'"
@mouseover="
@@ -239,17 +241,21 @@
@endif
@endif
</ul>
{{-- Sticky sidebar collapser (desktop only; mobile uses a temporary slide-over) --}}
<div class="sticky bottom-0 mt-auto -mx-2 hidden items-center gap-1 bg-white px-2 py-2 dark:bg-panel lg:-mx-3 lg:flex lg:px-3"
:class="collapsed ? 'flex-col-reverse justify-center' : 'justify-between'">
<x-top-user-menu sidebar />
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
class="menu-item sidebar-toggle w-8 shrink-0 justify-center px-0">
<svg class="menu-item-icon" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
{{-- Sticky sidebar footer (desktop only; mobile uses a temporary slide-over) --}}
<div class="sticky bottom-0 mt-auto -mx-2 hidden flex-col gap-1 bg-white px-2 py-2 dark:bg-panel lg:-mx-3 lg:flex lg:px-3">
@if ($deploymentsIndicator)
<livewire:deployments-indicator variant="sidebar" />
@endif
<div class="flex items-center gap-1" :class="collapsed ? 'flex-col-reverse justify-center' : 'justify-between'">
<x-top-user-menu sidebar />
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
class="menu-item sidebar-toggle w-8 shrink-0 justify-center px-0">
<svg class="menu-item-icon" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
</div>
</div>
<div x-show="collapsed && tooltip.show" x-cloak x-transition.opacity.duration.100ms
:style="`left: ${tooltip.x}px; top: ${tooltip.y}px;`"
+2 -3
View File
@@ -54,8 +54,6 @@
}" @open-global-search.window="open = false" @page-width-changed.window="pageWidth = $event.detail" x-on:livewire:navigated.window="applyCollapsed(true)"
:style="{ '--sidebar-w': collapsed ? '4rem' : '14rem' }" x-cloak
class="dark:text-inherit text-black">
<livewire:deployments-indicator />
{{-- ============ DESKTOP TOP BAR ============ --}}
<header
x-data="{ resourceActionsOpen: false }"
@@ -145,7 +143,7 @@
<div class="hidden lg:fixed lg:top-12 lg:bottom-0 lg:left-0 lg:z-40 lg:flex lg:flex-col min-w-0"
:class="[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']">
<div class="flex grow min-w-0 flex-col overflow-visible">
<x-navbar />
<x-navbar deployments-indicator />
</div>
</div>
@@ -166,6 +164,7 @@
<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>
<livewire:deployments-indicator variant="mobile" />
@if (isInstanceAdmin() && !isCloud())
<livewire:upgrade key="mobile-upgrade" />
@endif
@@ -1,95 +1,106 @@
<div wire:poll.3000ms x-on:livewire:navigated.window="
$wire.updateShouldShowFromPath(window.location.pathname || '/')
" x-data="{
expanded: @entangle('expanded')
}" class="fixed bottom-0 left-0 z-60 mb-4 ml-4 transition-[left] duration-200"
:class="collapsed ? 'lg:left-16' : 'lg:left-56'">
@if ($this->shouldShow && $this->deploymentCount > 0)
<div class="relative">
{{-- Expanded deployment list (above the pill) --}}
<div x-show="expanded" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-y-2" x-transition:enter-end="translate-y-0"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-2" x-cloak
class="surface-popover absolute bottom-full mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl">
<div class="max-h-96 space-y-1 overflow-y-auto p-2 scrollbar">
@foreach ($this->deployments as $deployment)
@php
$deploymentStatus = $deployment->status === 'in_progress' ? 'In progress' : 'Queued';
$deploymentStatusType = $deployment->status === 'in_progress' ? 'warning' : 'neutral';
$projectName = $deployment->application?->environment?->project?->name;
$environmentName = $deployment->application?->environment?->name;
$environmentPath = collect([$projectName, $environmentName])->filter()->join(' / ');
@endphp
<a wire:key="indicator-deployment-{{ $deployment->id }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="flex items-start gap-3 rounded-lg border border-transparent p-3 transition-colors hover:border-neutral-200 hover:bg-neutral-50 hover:no-underline dark:border-coolgray-300 dark:hover:border-coolgray-400 dark:hover:bg-raised">
<div
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-coollabs dark:border-coolgray-300 dark:bg-raised dark:text-warning">
@if ($deployment->status === 'in_progress')
<svg class="size-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
@else
<x-reicon name="time-back" class="size-3.5 text-neutral-500 dark:text-fg-dim" />
@endif
</div>
{{-- Rendered twice: in the desktop sidebar footer and in the mobile top bar. `.visible` pauses
polling for the copy hidden by the current breakpoint (a display:none element has no box). --}}
<div wire:poll.3000ms.visible x-data="{ expanded: false }" x-on:click.outside="expanded = false"
x-on:keydown.escape.window="expanded = false" @class(['relative' => $variant === 'sidebar'])>
@if ($this->deploymentCount > 0)
@php
$deploymentLabel = $this->deploymentCount.' '.Str::plural('deployment', $this->deploymentCount);
@endphp
{{-- Deployment list: opens beside the sidebar on desktop. On mobile the root is not positioned,
so the list anchors to the sticky top bar and spans its width. --}}
<div x-show="expanded" x-cloak x-transition.opacity.duration.150ms
@class([
'surface-popover absolute z-60 overflow-hidden rounded-xl',
'bottom-0 left-full ml-5 w-[22rem]' => $variant === 'sidebar',
'inset-x-4 top-full mt-2' => $variant === 'mobile',
])>
<div class="max-h-96 space-y-1 overflow-y-auto p-2 scrollbar">
@foreach ($this->deployments as $deployment)
@php
$deploymentStatus = $deployment->status === 'in_progress' ? 'In progress' : 'Queued';
$deploymentStatusType = $deployment->status === 'in_progress' ? 'warning' : 'neutral';
$projectName = $deployment->application?->environment?->project?->name;
$environmentName = $deployment->application?->environment?->name;
$environmentPath = collect([$projectName, $environmentName])->filter()->join(' / ');
@endphp
<a wire:key="indicator-deployment-{{ $deployment->id }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="flex items-start gap-3 rounded-lg border border-transparent p-3 transition-colors hover:border-neutral-200 hover:bg-neutral-50 hover:no-underline dark:border-coolgray-300 dark:hover:border-coolgray-400 dark:hover:bg-raised">
<div
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-coollabs dark:border-coolgray-300 dark:bg-raised dark:text-warning">
@if ($deployment->status === 'in_progress')
<svg class="size-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
@else
<x-reicon name="time-back" class="size-3.5 text-neutral-500 dark:text-fg-dim" />
@endif
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }}
</p>
<x-status-badge :status="$deploymentStatus" :type="$deploymentStatusType"
class="shrink-0" />
</div>
@if ($environmentPath !== '')
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $environmentPath }}
</p>
@endif
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $deployment->server_name ?: '-' }}
@if ($deployment->pull_request_id)
<span class="px-1 text-neutral-300 dark:text-fg-faint">·</span>
PR #{{ $deployment->pull_request_id }}
@endif
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }}
</p>
<x-status-badge :status="$deploymentStatus" :type="$deploymentStatusType"
class="shrink-0" />
</div>
</a>
@endforeach
</div>
</div>
{{-- Collapsed pill --}}
<button type="button" @click="expanded = !expanded"
class="flex items-center gap-2 rounded-xl border border-neutral-200 bg-white px-3.5 py-2 text-sm font-medium text-neutral-800 shadow-dropdown transition-colors hover:bg-neutral-50 dark:border-coolgray-300 dark:bg-surface dark:text-fg dark:hover:bg-raised"
:aria-expanded="expanded.toString()" aria-label="Active deployments">
<svg class="loading-indicator size-3.5 shrink-0 animate-spin"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
@if ($environmentPath !== '')
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $environmentPath }}
</p>
@endif
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $deployment->server_name ?: '-' }}
@if ($deployment->pull_request_id)
<span class="px-1 text-neutral-300 dark:text-fg-faint">·</span>
PR #{{ $deployment->pull_request_id }}
@endif
</p>
</div>
</a>
@endforeach
</div>
</div>
@if ($variant === 'sidebar')
<button type="button" x-on:click="expanded = !expanded" title="{{ $deploymentLabel }}"
aria-label="Active deployments" :aria-expanded="expanded.toString()"
class="menu-item w-full text-left text-coollabs dark:text-warning"
:class="[collapsed && 'lg:justify-center lg:px-0', expanded && 'menu-item-active']">
<span class="relative inline-flex">
<svg class="menu-item-icon animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span x-show="collapsed" x-cloak
class="absolute -right-1.5 -top-1.5 flex min-w-3.5 items-center justify-center rounded-full bg-coollabs px-1 text-[9px] font-semibold leading-3.5 text-white dark:bg-warning dark:text-black">{{ $this->deploymentCount }}</span>
</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">{{ $deploymentLabel }}</span>
</button>
@else
<button type="button" x-on:click="expanded = !expanded" title="{{ $deploymentLabel }}"
aria-label="Active deployments" :aria-expanded="expanded.toString()"
class="flex h-9 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-coollabs transition-colors hover:bg-neutral-100 dark:text-warning dark:hover:bg-white/[0.06]">
<svg class="size-3.5 shrink-0 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span>
{{ $this->deploymentCount }} {{ Str::plural('deployment', $this->deploymentCount) }}
</span>
<span class="inline-flex shrink-0 text-neutral-400 transition-transform duration-200 dark:text-fg-faint"
:class="{ 'rotate-180': expanded }">
<x-reicon name="chevron-down" class="size-3.5" />
</span>
<span>{{ $this->deploymentCount }}</span>
</button>
</div>
@endif
@endif
</div>
@@ -50,39 +50,27 @@ beforeEach(function () {
]);
});
it('hides the floating deployments indicator on the dashboard', function () {
$this->get(route('dashboard'))
->assertSuccessful()
->assertDontSee('1 deployment', false)
->assertDontSee('aria-label="Active deployments"', false);
});
it('shows the floating deployments indicator on non-dashboard pages', function () {
$this->get(route('project.index'))
it('shows the deployments indicator on every page, including the dashboard', function (string $routeName) {
$this->get(route($routeName))
->assertSuccessful()
->assertSee('1 deployment', false)
->assertSee('aria-label="Active deployments"', false);
});
})->with(['dashboard', 'project.index']);
it('keeps the indicator hidden across polls after mounting on the dashboard', function () {
$component = Livewire::test(DeploymentsIndicator::class)
->set('shouldShow', false)
->assertDontSee('1 deployment');
it('hides the indicator when no deployment is running', function () {
ApplicationDeploymentQueue::query()->update(['status' => ApplicationDeploymentStatus::FINISHED->value]);
// Polls call refresh methods without remounting; visibility must stay sticky.
$component
->call('$refresh')
->assertSet('shouldShow', false)
->assertDontSee('1 deployment');
});
it('updates visibility from the browser path after navigation', function () {
Livewire::test(DeploymentsIndicator::class)
->assertSet('shouldShow', true)
->call('updateShouldShowFromPath', '/')
->assertSet('shouldShow', false)
->assertDontSee('1 deployment')
->call('updateShouldShowFromPath', '/projects')
->assertSet('shouldShow', true)
->assertSee('1 deployment');
->assertDontSee('Active deployments');
});
it('does not show deployments of another team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
ApplicationDeploymentQueue::query()->update(['server_id' => $otherServer->id]);
Livewire::test(DeploymentsIndicator::class)
->assertDontSee('Indicator App')
->assertDontSee('Active deployments');
});