feat(admin): open controller-link settings entries from the settings index

A settings entry with a controller opens that controller's route from
its card (settingsPath); singleton cards still open /settings/<code>.
On a linked controller with no navigation entry of its own the rail
marks Settings current, the breadcrumbs read Settings > entry label
(plus the record title on record and create routes) and the page title
uses the entry label. The settings docs describe the admin behaviour;
the embedded admin shell is rebuilt.
This commit is contained in:
Jakub Zych
2026-10-07 18:24:54 +02:00
parent 4a9b0896b1
commit 0b09acdb0c
13 changed files with 149 additions and 19 deletions

View File

@@ -3,7 +3,7 @@ import { controllerIdFromPath } from './controllerRoutes'
import { t } from './i18n'
import { recordCrumb } from '../state/useBreadcrumbs'
import { activeEntry } from '../state/useNavigation'
import { settingsEntry } from '../state/useSettings'
import { settingsEntry, settingsLinkFor } from '../state/useSettings'
const PRODUCT_TITLE = 'SummerCMS'
@@ -28,7 +28,7 @@ export function pageIndicator(route: RouteLocationNormalizedLoaded): string {
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
const controller = controllerIdFromPath(route.path)
const section = plugin?.sideMenu.find((item) => item.controller === controller)
const controllerLabel = section?.label ?? plugin?.label ?? ''
const controllerLabel = section?.label ?? plugin?.label ?? settingsLinkFor(controller)?.label ?? ''
if (route.name === 'create' || route.name === 'record' || route.name === 'preview') {
return recordCrumb.value || controllerLabel
}

View File

@@ -6,12 +6,14 @@ import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes
import { t } from '../../app/i18n'
import { recordCrumb } from '../../state/useBreadcrumbs'
import { activeEntry, firstControllerPath } from '../../state/useNavigation'
import { settingsEntry } from '../../state/useSettings'
import { settingsEntry, settingsLinkFor, settingsPath } from '../../state/useSettings'
// Header breadcrumbs (design: Top header): plugin, controller and record
// labels, e.g. "Demo › Widgets › Blue widget". Parent crumbs are muted
// links; the current crumb uses the text colour at 600 with
// aria-current="page". Separators are 14px chevron-right icons.
// aria-current="page". Separators are 14px chevron-right icons. A controller
// opened from a settings link entry, with no navigation entry of its own,
// reads "Settings › <entry label>".
interface Crumb {
label: string
to?: string
@@ -29,11 +31,22 @@ const crumbs = computed<Crumb[]>(() => {
return out
}
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
const id = controllerIdFromPath(route.path)
if (!plugin) {
return []
const link = settingsLinkFor(id)
if (!link) {
return []
}
const out: Crumb[] = [
{ label: t('backend::lang.nav.settings'), to: '/settings' },
{ label: link.label, to: settingsPath(link) },
]
if ((route.name === 'record' || route.name === 'create') && recordCrumb.value !== '') {
out.push({ label: recordCrumb.value })
}
return out
}
const out: Crumb[] = [{ label: plugin.label, to: firstControllerPath(plugin) ?? undefined }]
const id = controllerIdFromPath(route.path)
const section = plugin.sideMenu.find((item) => item.controller === id) ?? null
if (section) {
out.push({ label: section.label, to: controllerPath(section.controller) ?? undefined })

View File

@@ -3,9 +3,10 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { PanelLeftOpen, Settings, Sun } from '@lucide/vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
import { hasSettings } from '../../state/useSettings'
import { hasSettings, settingsLinkFor } from '../../state/useSettings'
import { useSidebar } from '../../state/useSidebar'
import SectionFlyout from './SectionFlyout.vue'
@@ -25,8 +26,15 @@ const { collapsed, narrow, setCollapsed } = useSidebar()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
// Ustawienia (D-21) is pinned to the bottom and shown only when the admin may
// manage at least one settings page.
const inSettings = computed(() => route.path === '/settings' || route.path.startsWith('/settings/'))
// manage at least one settings page. It is also current on a controller a
// settings entry links to, unless that controller has its own navigation
// entry (then its plugin stays highlighted).
const inSettings = computed(
() =>
route.path === '/settings' ||
route.path.startsWith('/settings/') ||
(active.value === null && settingsLinkFor(controllerIdFromPath(route.path)) !== null),
)
const items = computed(() =>
railEntries.value.map((entry) => ({

View File

@@ -1,7 +1,10 @@
// Settings pages the admin may manage (D-21), server-filtered like the
// navigation. The rail shows "Ustawienia" only when this list is non-empty.
// An entry is either a singleton settings form (/settings/<code>) or a link
// that opens an admin controller (its `controller` is set).
import { computed, readonly, ref } from 'vue'
import { api } from '../api/client'
import { controllerPath } from '../app/controllerRoutes'
import type { SettingsEntry } from '../api/types'
const entries = ref<SettingsEntry[]>([])
@@ -24,6 +27,25 @@ export function settingsEntry(code: string): SettingsEntry | null {
return entries.value.find((entry) => entry.code === code) ?? null
}
/**
* Route a settings entry opens: its controller for a link entry (the index
* when the ID is malformed), otherwise its singleton form.
*/
export function settingsPath(entry: SettingsEntry): string {
if (entry.controller) {
return controllerPath(entry.controller) ?? '/settings'
}
return `/settings/${encodeURIComponent(entry.code)}`
}
/** The link entry that opens the given controller, if any. */
export function settingsLinkFor(controllerId: string | null): SettingsEntry | null {
if (!controllerId) {
return null
}
return entries.value.find((entry) => entry.controller !== '' && entry.controller === controllerId) ?? null
}
/** Entries grouped by category, categories in first-seen order, by order. */
export const settingsGroups = computed(() => {
const sorted = entries.value
@@ -43,5 +65,5 @@ export const settingsGroups = computed(() => {
})
export function useSettings() {
return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry }
return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry, settingsPath, settingsLinkFor }
}

View File

@@ -2,10 +2,11 @@
import { RouterLink } from 'vue-router'
import { t } from '../app/i18n'
import { iconFor } from '../app/icons'
import { settingsGroups } from '../state/useSettings'
import { settingsGroups, settingsPath } from '../state/useSettings'
// Settings index (D-21): every permitted settings page, grouped by category,
// with its icon, label and description. The server filters the list.
// with its icon, label and description. The server filters the list. A
// singleton entry opens its settings form; a link entry opens its controller.
</script>
<template>
@@ -27,7 +28,7 @@ import { settingsGroups } from '../state/useSettings'
<RouterLink
v-for="entry in group.entries"
:key="entry.code"
:to="`/settings/${encodeURIComponent(entry.code)}`"
:to="settingsPath(entry)"
:data-settings="entry.code"
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
>

View File

@@ -33,6 +33,17 @@ describe('documentTitleForRoute', () => {
expect(documentTitleForRoute((await routerAt('/settings/mail')).currentRoute.value)).toBe('Mail | SummerCMS')
})
it('uses the settings entry label on a settings-linked controller', async () => {
await signIn()
setNavigation(navigationFixture.data)
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
setSettings([...settingsFixture.list.data, locales])
expect(documentTitleForRoute((await routerAt('/acme/lang/locales')).currentRoute.value)).toBe('Locales | SummerCMS')
expect(documentTitleForRoute((await routerAt('/acme/lang/locales/3')).currentRoute.value)).toBe('Locales | SummerCMS')
setRecordCrumb('Polish')
expect(documentTitleForRoute((await routerAt('/acme/lang/locales/3')).currentRoute.value)).toBe('Polish | SummerCMS')
})
it('uses translated indicators for login, the empty home and not found', async () => {
expect(documentTitleForRoute((await routerAt('/login')).currentRoute.value)).toBe('Witaj ponownie | SummerCMS')
await signIn()

View File

@@ -53,6 +53,14 @@ describe('settings index (D-21)', () => {
expect(link.text()).toBe('Search')
expect(wrapper.find('[data-settings-group] h2').exists()).toBe(false)
})
it('opens a controller-link entry on its controller route and keeps singleton links', async () => {
const link = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.demo.locales' }
setSettings([...settingsFixture.list.data, link])
const { wrapper } = await mountApp('/settings', routes())
expect(wrapper.find('[data-settings="locales"]').attributes('href')).toMatch(/\/acme\/demo\/locales$/)
expect(wrapper.find('[data-settings="mail"]').attributes('href')).toBe('/settings/mail')
})
})
describe('settings form (D-21)', () => {

View File

@@ -72,6 +72,37 @@ describe('breadcrumbs', () => {
expect((await crumbsAt('/settings/unknown')).labels).toEqual(['Ustawienia'])
})
it('reads Ustawienia and the entry label on a settings-linked controller', async () => {
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
const crumbsWithLink = async (path: string) => {
await signIn()
setNavigation(navigationFixture.data)
setSettings([...settingsFixture.list.data, locales])
const router = await routerAt(path)
const wrapper = mount(Breadcrumbs, { global: { plugins: [router] } })
await flushPromises()
const items = wrapper.findAll('[data-crumb]')
return {
labels: items.map((item) => item.text()),
links: items.map((item) => (item.find('a').exists() ? item.find('a').attributes('href') : null)),
current: items.map((item) => item.find('[aria-current="page"]').exists()),
}
}
let crumbs = await crumbsWithLink('/acme/lang/locales')
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales'])
expect(crumbs.links).toEqual(['/settings', null])
expect(crumbs.current).toEqual([false, true])
setRecordCrumb('Polish')
crumbs = await crumbsWithLink('/acme/lang/locales/3')
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales', 'Polish'])
expect(crumbs.links).toEqual(['/settings', '/acme/lang/locales', null])
setRecordCrumb('')
crumbs = await crumbsWithLink('/acme/lang/locales/create')
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales'])
})
it('is empty outside any permitted plugin', async () => {
expect((await crumbsAt('/acme/secret/things')).labels).toEqual([])
expect((await crumbsAt('/acme/demo/unlisted')).labels).toEqual(['Demo'])

View File

@@ -59,6 +59,20 @@ describe('plugin rail', () => {
expect(link.attributes('aria-current')).toBe('page')
})
it('marks Ustawienia current on a settings-linked controller without a navigation entry', async () => {
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
const widgets = { ...locales, code: 'widgets', label: 'Widgets', controller: 'acme.demo.widgets' }
setSettings([...settingsFixture.list.data, locales, widgets])
const { wrapper } = await mountRail('/acme/lang/locales')
expect(wrapper.find('[data-rail-settings]').attributes('aria-current')).toBe('page')
expect(wrapper.findAll('[data-rail-item][aria-current="page"]')).toHaveLength(0)
// A linked controller that also has a navigation entry keeps its plugin.
const nav = await mountRail('/acme/demo/widgets')
expect(nav.wrapper.find('[data-rail-settings]').attributes('aria-current')).toBeUndefined()
expect(nav.wrapper.find('[data-rail-item="demo"]').attributes('aria-current')).toBe('page')
})
it('offers menu semantics and the expand button after a manual collapse', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)

View File

@@ -6,6 +6,8 @@ import {
settings,
settingsEntry,
settingsGroups,
settingsLinkFor,
settingsPath,
useSettings,
} from '../../src/state/useSettings'
import { settingsFixture } from '../fixtures/typed'
@@ -44,4 +46,22 @@ describe('useSettings (D-21)', () => {
['System', ['mail', 'search']],
])
})
it('links a singleton to its form and a controller link to the controller route', () => {
const singleton = settingsFixture.list.data[0]!
expect(settingsPath({ ...singleton, code: 'a b' })).toBe('/settings/a%20b')
expect(settingsPath({ ...singleton, code: 'locales', model: '', controller: 'acme.demo.locales' })).toBe('/acme/demo/locales')
expect(settingsPath({ ...singleton, code: 'broken', model: '', controller: 'acme.demo' })).toBe('/settings')
expect(useSettings().settingsPath).toBe(settingsPath)
})
it('finds the link entry that opens a controller, never a singleton', () => {
const link = { ...settingsFixture.list.data[0]!, code: 'locales', model: '', controller: 'acme.demo.locales' }
setSettings([...settingsFixture.list.data, link])
expect(settingsLinkFor('acme.demo.locales')?.code).toBe('locales')
expect(settingsLinkFor('acme.demo.widgets')).toBeNull()
expect(settingsLinkFor('')).toBeNull()
expect(settingsLinkFor(null)).toBeNull()
expect(useSettings().settingsLinkFor).toBe(settingsLinkFor)
})
})