mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 01:10:30 -04:00
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:
@@ -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. */
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
Reference in New Issue
Block a user