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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user