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"
>