mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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 === '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;`"
|
||||
|
||||
@@ -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>
|
||||
|
||||
+17
-29
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user