From 7585480670b65e23df7da2ec915ef76a640970a7 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 12:39:14 +0000 Subject: [PATCH] feat(ui): collapse resource settings sidebar groups into an accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- resources/css/utilities.css | 5 +++ resources/js/app.js | 2 + resources/js/settings-sidebar-accordion.js | 38 +++++++++++++++++++ .../configuration-sidebar.blade.php | 16 +++++++- .../database/configuration-sidebar.blade.php | 16 +++++++- .../views/components/server/sidebar.blade.php | 20 +++++++++- .../service/configuration-sidebar.blade.php | 38 +++++++++++++------ .../Feature/SettingsSidebarAccordionTest.php | 31 +++++++++++++++ 8 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 resources/js/settings-sidebar-accordion.js create mode 100644 tests/Feature/SettingsSidebarAccordionTest.php diff --git a/resources/css/utilities.css b/resources/css/utilities.css index f6eed911fd..96511bbf79 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -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. */ diff --git a/resources/js/app.js b/resources/js/app.js index 2e0056011b..b912d827e6 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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 diff --git a/resources/js/settings-sidebar-accordion.js b/resources/js/settings-sidebar-accordion.js new file mode 100644 index 0000000000..a81fdd1a94 --- /dev/null +++ b/resources/js/settings-sidebar-accordion.js @@ -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.) + } + }, + })); +} diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 604513b269..7ecae8856e 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -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, ])> diff --git a/resources/views/components/database/configuration-sidebar.blade.php b/resources/views/components/database/configuration-sidebar.blade.php index 2c62641f85..1805efc863 100644 --- a/resources/views/components/database/configuration-sidebar.blade.php +++ b/resources/views/components/database/configuration-sidebar.blade.php @@ -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 @@ diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 75fb43e2d7..b9627375f6 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -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 diff --git a/resources/views/components/service/configuration-sidebar.blade.php b/resources/views/components/service/configuration-sidebar.blade.php index 5e58b99b08..6ad001c0c6 100644 --- a/resources/views/components/service/configuration-sidebar.blade.php +++ b/resources/views/components/service/configuration-sidebar.blade.php @@ -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 diff --git a/tests/Feature/SettingsSidebarAccordionTest.php b/tests/Feature/SettingsSidebarAccordionTest.php new file mode 100644 index 0000000000..fed1ea36d3 --- /dev/null +++ b/tests/Feature/SettingsSidebarAccordionTest.php @@ -0,0 +1,31 @@ + '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'"); +});