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, ])> @foreach ($groupedMenuItems as $groupLabel => $groupItems) @unless ($loop->first) @endunless - {{ $groupLabel }} + + {{ $groupLabel }} + + + + + @foreach ($groupItems as $menuItem) @php $sections = $pageSections[$menuItem['route']] ?? []; @endphp @@ -297,6 +310,7 @@ @endif @endforeach + @endforeach 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'"); +});