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) {
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)
})
})

View File

@@ -85,6 +85,8 @@ A link entry is not a singleton and needs no embedded admin tree. [cabana](../..
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
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.
## How values are stored
The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does.

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-kMerM-BR.js"></script>
<script type="module" crossorigin src="./assets/index-BK2Ktgov.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BhDUy2Lr.css">
</head>
<body>