From 834ef9857212870260b3effb21525739a30e6e21 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 13:50:39 +0200 Subject: [PATCH] feat(quick-261022-imo): settings section panel scoped to the current category - settingsSectionOf returns the current entry's category group (null for one-entry categories) - SettingsPanel mirrors the plugin side menu, collapse included - AppShell shows it on settings forms and settings-linked controllers without own navigation - docs/backend/settings.md describes moving between settings pages --- admin/src/components/shell/AppShell.vue | 25 ++++++- admin/src/components/shell/SettingsPanel.vue | 68 ++++++++++++++++++++ admin/src/state/useSettings.ts | 37 ++++++++++- admin/tests/shell/AppShell.test.ts | 20 +++++- docs/backend/settings.md | 8 ++- 5 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 admin/src/components/shell/SettingsPanel.vue diff --git a/admin/src/components/shell/AppShell.vue b/admin/src/components/shell/AppShell.vue index 444ff833..c573f4ba 100644 --- a/admin/src/components/shell/AppShell.vue +++ b/admin/src/components/shell/AppShell.vue @@ -3,16 +3,20 @@ import { computed, onBeforeUnmount } from 'vue' import { useRoute } from 'vue-router' import PluginRail from './PluginRail.vue' import SectionPanel from './SectionPanel.vue' +import SettingsPanel from './SettingsPanel.vue' import Breadcrumbs from './Breadcrumbs.vue' import UserMenu from './UserMenu.vue' import Toast from '../ui/Toast.vue' import { ExternalLink } from '@lucide/vue' +import { controllerIdFromPath } from '../../app/controllerRoutes' import { activeEntry } from '../../state/useNavigation' +import { settingsEntry, settingsLinkFor, settingsSectionOf } from '../../state/useSettings' import { useSidebar } from '../../state/useSidebar' import { t } from '../../app/i18n' // Navigation shell (design: Navigation shell and Top header; D-06, D-10): -// the plugin rail, the active plugin's section panel (hidden when the +// the plugin rail, the section panel (the active plugin's side menu or, on a +// settings page, the settings panel of its category; hidden when the // sidebar is collapsed by the viewport or by the admin), and the 64px header // with breadcrumbs and the user menu. The toast queue is mounted here so // toasts survive navigation. @@ -28,11 +32,27 @@ const siteURL = `${window.location.origin}/` const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))) +// The settings panel shows on a singleton settings form and on a controller a +// settings link entry opens, unless that controller's plugin has a navigation +// entry of its own (it keeps its plugin panel, as the rail's Settings rule +// does). The /settings index has no controller ID and resolves to null. +const settingsSection = computed(() => { + if (active.value) { + return null + } + if (route.name === 'settings-form') { + return settingsSectionOf(settingsEntry(String(route.params.code ?? ''))) + } + return settingsSectionOf(settingsLinkFor(controllerIdFromPath(route.path))) +}) + // --shell-offset is the width the sidebar occupies (the rail, plus the // section panel when it shows). Fixed bottom save bars start there, so the // rail's Settings item is never covered. const shellOffset = computed(() => - active.value && !collapsed.value ? 'calc(var(--spacing-rail) + var(--spacing-panel))' : 'var(--spacing-rail)', + (active.value || settingsSection.value) && !collapsed.value + ? 'calc(var(--spacing-rail) + var(--spacing-panel))' + : 'var(--spacing-rail)', ) @@ -40,6 +60,7 @@ const shellOffset = computed(() =>
+
diff --git a/admin/src/components/shell/SettingsPanel.vue b/admin/src/components/shell/SettingsPanel.vue new file mode 100644 index 00000000..dc10d73e --- /dev/null +++ b/admin/src/components/shell/SettingsPanel.vue @@ -0,0 +1,68 @@ + + + diff --git a/admin/src/state/useSettings.ts b/admin/src/state/useSettings.ts index c614dc22..b2213656 100644 --- a/admin/src/state/useSettings.ts +++ b/admin/src/state/useSettings.ts @@ -64,6 +64,39 @@ export const settingsGroups = computed(() => { return groups }) -export function useSettings() { - return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry, settingsPath, settingsLinkFor } +/** The settings pages of one category, with the page on screen. */ +export interface SettingsSection { + category: string + current: SettingsEntry + entries: SettingsEntry[] +} + +/** + * The category group of the given entry for the shell's settings panel, in + * settingsGroups order and without entries of any other category. Null when + * there is no current entry or its category holds a single page: there is + * nothing to switch to. + */ +export function settingsSectionOf(current: SettingsEntry | null): SettingsSection | null { + if (!current) { + return null + } + const group = settingsGroups.value.find((item) => item.category === current.category) + if (!group || group.entries.length < 2) { + return null + } + return { category: group.category, current, entries: group.entries } +} + +export function useSettings() { + return { + settings, + hasSettings, + settingsGroups, + loadSettings, + settingsEntry, + settingsPath, + settingsLinkFor, + settingsSectionOf, + } } diff --git a/admin/tests/shell/AppShell.test.ts b/admin/tests/shell/AppShell.test.ts index 9d2e1496..fcd13730 100644 --- a/admin/tests/shell/AppShell.test.ts +++ b/admin/tests/shell/AppShell.test.ts @@ -4,12 +4,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import AppShell from '../../src/components/shell/AppShell.vue' import { setNavigation } from '../../src/state/useNavigation' +import { setSettings } from '../../src/state/useSettings' import { NARROW_QUERY, setCollapsed } from '../../src/state/useSidebar' import { showToast } from '../../src/state/useToasts' import { navigationFixture } from '../fixtures/typed' import { setBundle } from '../../src/app/i18n' import { fakeMedia, resetState, routerAt, signIn } from '../helpers' -import { langFixture } from '../fixtures/typed' +import { langFixture, settingsFixture } from '../fixtures/typed' async function mountShell(path: string) { await signIn() @@ -134,3 +135,20 @@ describe('View site link (Phase 16.2 quick follow-up)', () => { expect((wrapper.element as HTMLElement).style.getPropertyValue('--shell-offset')).toBe('var(--spacing-rail)') }) }) + +describe('settings section panel', () => { + it('lists the current category on a settings form, current entry highlighted', async () => { + setSettings(settingsFixture.list.data) + const wrapper = await mountShell('/settings/mail') + const panel = wrapper.find('aside[data-settings-panel]') + expect(panel.exists()).toBe(true) + expect(panel.find('span').text()).toBe('System') + const links = panel.findAll('[data-settings-panel-item]') + expect(links.map((link) => [link.text(), link.attributes('href')])).toEqual([ + ['Mail', '/settings/mail'], + ['Search', '/settings/search'], + ]) + expect(links[0]!.attributes('aria-current')).toBe('page') + expect(links[1]!.attributes('aria-current')).toBeUndefined() + }) +}) diff --git a/docs/backend/settings.md b/docs/backend/settings.md index 9f3d7bf9..4498e68f 100644 --- a/docs/backend/settings.md +++ b/docs/backend/settings.md @@ -89,7 +89,13 @@ A link entry is not a singleton and needs no embedded admin tree. [cabana](../.. A link entry may also open a [singleton controller](admin-controllers.md#singleton-controllers), one whose `pact.AdminSingleton` record the admin opens directly in its form, the way WinterCMS plugins auto-created one definition and redirected to it. The entry stays a link to the controller's list route: since a singleton controller has no list, the admin asks its singleton route for the record and opens it, also when the link is opened in a new tab. -In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current and the breadcrumbs read Settings, then the entry's label. +In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current, the breadcrumbs read Settings, then the entry's label, and the section panel lists the settings pages of the entry's category. + +## Moving between settings pages + +On a settings form, and on a linked controller that belongs to Settings, a panel next to the rail lists the settings pages of the same category in the order of the Settings page, with the open page highlighted. An administrator on one page of a category reaches the others directly, the way a plugin's side menu switches between its controllers. + +The panel does not appear when the category has a single page, on the Settings page itself, or on a linked controller that has its own main navigation item; that controller keeps its plugin's side menu. Pages of another category are reached through the Settings page. The panel collapses and expands like a plugin's side menu and hides on narrow windows. ## How values are stored