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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user