feat(ui): collapse resource settings sidebar groups into an accordion

Discussion #11833: since v4.3.19 the resource settings sidebar expands every
group by default, so reaching Backups etc. means scrolling past every section.

Restore the v4.3.18 behaviour across all grouped resource sidebars (application,
database, service, server): each group header is now a collapsible toggle, and
only the group containing the active page is open by default. Manual expand/
collapse is remembered per resource type in localStorage; an explicit collapse
sticks even for the active group. Desktop (xl) only — the mobile grid is
unchanged (display:contents wrapper + xl-scoped collapse).

- new shared Alpine provider settingsSidebarAccordion (resources/js) + app.js
- nav-section-toggle utility (chevron header)
- wired into the four grouped sidebars
- static regression test
This commit is contained in:
Aditya Tripathi
2026-09-16 12:39:14 +00:00
parent 2f5b03aee0
commit 7585480670
8 changed files with 151 additions and 15 deletions
+5
View File
@@ -249,6 +249,11 @@
@apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none;
}
/* Collapsible group header (accordion) for the resource settings sidebar. */
@utility nav-section-toggle {
@apply w-full items-center justify-between gap-2 px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none rounded-md transition-colors cursor-pointer hover:text-nav-active;
}
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
/* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
+2
View File
@@ -1,4 +1,5 @@
import { initializeCopyButtonComponent } from './copy-button.js';
import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js';
import { initializeTerminalComponent } from './terminal.js';
import './traffic-globe.js';
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
@@ -20,6 +21,7 @@ document.addEventListener('livewire:navigated', () => {
// available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent);
document.addEventListener('alpine:init', initializeCopyButtonComponent);
document.addEventListener('alpine:init', initializeSettingsSidebarAccordionComponent);
/**
* Smooth-scroll a settings section into view, then flash its border for 500ms
@@ -0,0 +1,38 @@
// Alpine data provider for the collapsible resource settings sidebar
// (x-data="settingsSidebarAccordion({ activeGroup, storageKey })").
//
// Only the group that contains the current page is open by default; every group
// can be collapsed/expanded and the choice is remembered per resource type. The
// active group is always forced open on load so the current page stays reachable.
export function initializeSettingsSidebarAccordionComponent() {
window.Alpine.data('settingsSidebarAccordion', (config = {}) => ({
activeGroup: config.activeGroup || '',
storageKey: config.storageKey || 'coolify.settings-sidebar',
groups: {},
init() {
let stored = {};
try {
stored = JSON.parse(localStorage.getItem(this.storageKey)) || {};
} catch (e) {
stored = {};
}
this.groups = stored && typeof stored === 'object' ? stored : {};
},
isOpen(group) {
// Explicit user choice wins (so the active group can be collapsed too);
// otherwise only the active group is open by default.
if (Object.prototype.hasOwnProperty.call(this.groups, group)) {
return this.groups[group];
}
return group === this.activeGroup;
},
toggle(group) {
this.groups = { ...this.groups, [group]: !this.isOpen(group) };
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.groups));
} catch (e) {
// ignore storage errors (private mode, quota, etc.)
}
},
}));
}
@@ -179,6 +179,9 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
$activeGroup = (string) $groupedMenuItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
// In-page sections (cards) shown as sub-items under the active page
$isComposeApp = $application->build_pack === 'dockercompose';
$pageSections = [
@@ -249,12 +252,22 @@
'is-flush' => $flush,
])>
<nav aria-label="Configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}">
@@ -297,6 +310,7 @@
@endif
</div>
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -46,6 +46,9 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
$pageSections = $database->type() === 'standalone-postgresql'
? [
['id' => 'database-details-section', 'label' => 'Database details'],
@@ -62,12 +65,22 @@
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Database settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.database' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
@@ -94,6 +107,7 @@
</div>
@endif
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -176,6 +176,13 @@
->filter(fn (array $item): bool => $item['visible'] ?? true)
->values();
$groupedServerMenuItems = $serverMenuItems->groupBy('group');
// Group that holds the current page (item or nested child) — the only one
// expanded by default.
$activeGroup = (string) $groupedServerMenuItems->search(fn ($items) => $items->contains(
fn ($item) => ($item['active'] ?? false)
|| collect($item['children'] ?? [])->contains(fn ($child) => $child['active'] ?? false)
));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
@@ -188,13 +195,23 @@
traefikOutdated = $event.detail.traefikOutdated;
">
<nav aria-label="Server configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.server' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedServerMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
<a wire:key="server-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
@@ -228,6 +245,7 @@
</div>
@endif
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -46,27 +46,41 @@
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['hasWarning'] ?? false)
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
@endif
</a>
@endforeach
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['hasWarning'] ?? false)
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
@endif
</a>
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -0,0 +1,31 @@
<?php
// Discussion #11833: the resource settings sidebar must not expand every group by
// default. Each grouped sidebar wires up the shared accordion so only the group
// containing the active page is open by default (client-side, via localStorage).
$groupedSidebars = [
'application' => 'resources/views/components/application/configuration-sidebar.blade.php',
'database' => 'resources/views/components/database/configuration-sidebar.blade.php',
'service' => 'resources/views/components/service/configuration-sidebar.blade.php',
'server' => 'resources/views/components/server/sidebar.blade.php',
];
it('wires the collapsible accordion into every grouped settings sidebar', function (string $file) {
$contents = file_get_contents(base_path($file));
expect($contents)
->toContain('settingsSidebarAccordion(') // shared Alpine data provider
->toContain('$activeGroup') // only the active group opens by default
->toContain('nav-section-toggle') // group header is a toggle button
->toContain("toggle(") // header collapses/expands the group
->toContain("? 'xl:block' : 'xl:hidden'"); // desktop-only collapse wrapper
})->with($groupedSidebars);
it('registers the accordion Alpine provider', function () {
expect(file_get_contents(base_path('resources/js/app.js')))
->toContain('initializeSettingsSidebarAccordionComponent');
expect(file_get_contents(base_path('resources/js/settings-sidebar-accordion.js')))
->toContain("Alpine.data('settingsSidebarAccordion'");
});