fix(gui-v2): breadcrumb navigation via router.push + button type + void logout
- FIX A (IMPORTANT): PrimeVue Breadcrumb ignores `route` key; map non-last items with `command: () => router.push(item.to)` for real client-side nav - FIX B: add type="button" to all 6 native <button> chrome elements - FIX C: authStore.logout() bare call matches project no-void pattern - FIX D: document param-route edge case in toBreadcrumbItems - FIX E: regression test asserts command+push on non-last, no command on last, no `route` key on any item Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import Menu from 'primevue/menu'
|
|||||||
import OverlayBadge from 'primevue/overlaybadge'
|
import OverlayBadge from 'primevue/overlaybadge'
|
||||||
import Popover from 'primevue/popover'
|
import Popover from 'primevue/popover'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import type { MenuItem } from 'primevue/menuitem'
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import Icon from '@/components/Icon.vue'
|
import Icon from '@/components/Icon.vue'
|
||||||
import { useBreadcrumb } from '@/composables/useBreadcrumb'
|
import { useBreadcrumb } from '@/composables/useBreadcrumb'
|
||||||
@@ -45,11 +46,12 @@ import { useShellUiStore } from '@/stores/useShellUiStore'
|
|||||||
import { computeOrgGradient } from '@/utils/v2/gradient'
|
import { computeOrgGradient } from '@/utils/v2/gradient'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stores
|
// Stores / router
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const shell = useShellUiStore()
|
const shell = useShellUiStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Breadcrumb — route-driven via useBreadcrumb()
|
// Breadcrumb — route-driven via useBreadcrumb()
|
||||||
@@ -59,14 +61,27 @@ const { items: breadcrumbItems } = useBreadcrumb()
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Map BreadcrumbItem[] → PrimeVue MenuItem[].
|
* Map BreadcrumbItem[] → PrimeVue MenuItem[].
|
||||||
* Non-last items get a `route` for router-link rendering.
|
*
|
||||||
* Last item (current) has no route/command — plain label only.
|
* The installed PrimeVue Breadcrumb (BreadcrumbItem.vue) renders
|
||||||
|
* `<a :href="item.url || '#'">` and calls `item.command` on click.
|
||||||
|
* It does NOT honour a `route` key — router-link is never invoked.
|
||||||
|
*
|
||||||
|
* Fix: non-last items navigate via `command: () => router.push(item.to)` (client-side,
|
||||||
|
* no full reload, no href="#"). Last/current item has no `to` from useBreadcrumb()
|
||||||
|
* → no command → non-interactive.
|
||||||
*/
|
*/
|
||||||
const breadcrumbModel = computed<MenuItem[]>(() =>
|
const breadcrumbModel = computed<MenuItem[]>(() =>
|
||||||
breadcrumbItems.value.map(item => ({
|
breadcrumbItems.value.map(item => {
|
||||||
label: item.label,
|
const base: MenuItem = { label: item.label }
|
||||||
...(item.to !== undefined ? { route: item.to } : {}),
|
|
||||||
})),
|
if (item.to !== undefined) {
|
||||||
|
base.command = () => {
|
||||||
|
router.push(item.to!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -214,7 +229,9 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
|||||||
{
|
{
|
||||||
label: 'Sign out',
|
label: 'Sign out',
|
||||||
icon: 'tabler-logout',
|
icon: 'tabler-logout',
|
||||||
command: () => authStore.logout(),
|
command: () => {
|
||||||
|
authStore.logout()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -235,6 +252,7 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
|||||||
.icon-btn: 38x38, rounded, transparent bg, fg-muted color, hover bg-hover
|
.icon-btn: 38x38, rounded, transparent bg, fg-muted color, hover bg-hover
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="lg:hidden inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
class="lg:hidden inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
@click="shell.setMobileOpen(true)"
|
@click="shell.setMobileOpen(true)"
|
||||||
@@ -295,6 +313,7 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
|||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
v-if="authStore.currentOrganisation"
|
v-if="authStore.currentOrganisation"
|
||||||
|
type="button"
|
||||||
class="ws-mobile-btn-shadow lg:hidden inline-flex h-[38px] w-[38px] flex-shrink-0 items-center justify-center rounded-[var(--p-border-radius)] border-0 text-[12px] font-bold text-white"
|
class="ws-mobile-btn-shadow lg:hidden inline-flex h-[38px] w-[38px] flex-shrink-0 items-center justify-center rounded-[var(--p-border-radius)] border-0 text-[12px] font-bold text-white"
|
||||||
:style="{
|
:style="{
|
||||||
background: `linear-gradient(135deg, ${mobileOrgGradient[0]}, ${mobileOrgGradient[1]})`,
|
background: `linear-gradient(135deg, ${mobileOrgGradient[0]}, ${mobileOrgGradient[1]})`,
|
||||||
@@ -332,6 +351,7 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
|||||||
.icon-btn: 38x38, inline-flex, centered, rounded, transparent, fg-muted, hover bg-hover
|
.icon-btn: 38x38, inline-flex, centered, rounded, transparent, fg-muted, hover bg-hover
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||||
:aria-label="densityAriaLabel"
|
:aria-label="densityAriaLabel"
|
||||||
@click="toggleDensity"
|
@click="toggleDensity"
|
||||||
@@ -344,6 +364,7 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
|||||||
|
|
||||||
<!-- Theme toggle. -->
|
<!-- Theme toggle. -->
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||||
:aria-label="themeAriaLabel"
|
:aria-label="themeAriaLabel"
|
||||||
@click="toggleTheme"
|
@click="toggleTheme"
|
||||||
@@ -364,6 +385,7 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
|||||||
severity="danger"
|
severity="danger"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
@click="toggleNotifPopover"
|
@click="toggleNotifPopover"
|
||||||
|
|||||||
@@ -9,9 +9,14 @@
|
|||||||
* 2. Theme toggle click → shell.setTheme with flipped value (light→dark, dark→light)
|
* 2. Theme toggle click → shell.setTheme with flipped value (light→dark, dark→light)
|
||||||
* 3. Density toggle click → shell.setDensity with flipped value
|
* 3. Density toggle click → shell.setDensity with flipped value
|
||||||
* 4. User-menu Sign out command → authStore.logout called
|
* 4. User-menu Sign out command → authStore.logout called
|
||||||
|
* 5. Breadcrumb model mapping:
|
||||||
|
* - non-last items carry a `command` that calls router.push with the item's `to`
|
||||||
|
* - last item has NO `command` (non-interactive) and NO `route` key (FIX A regression)
|
||||||
*
|
*
|
||||||
* useBreadcrumb() calls useRoute() internally. We provide a minimal
|
* useBreadcrumb() calls useRoute() internally. We provide a minimal
|
||||||
* vue-router mock via vi.mock so the composable has a route to call.
|
* vue-router mock via vi.mock so the composable has a route to call.
|
||||||
|
* useRoute is exposed as a vi.fn() so individual tests can override the
|
||||||
|
* matched records without re-importing the module.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
@@ -22,13 +27,21 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock vue-router (useBreadcrumb calls useRoute internally)
|
// Mock vue-router (useBreadcrumb calls useRoute internally)
|
||||||
|
// vi.hoisted() is required so these vi.fn() instances are initialised
|
||||||
|
// before the vi.mock() factory runs (vi.mock is hoisted to top of file by
|
||||||
|
// the Vitest transform, but const declarations are not).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { mockRouterPush, mockUseRoute } = vi.hoisted(() => ({
|
||||||
|
mockRouterPush: vi.fn(),
|
||||||
|
mockUseRoute: vi.fn(() => ({ matched: [] as unknown[] })),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => ({
|
useRoute: mockUseRoute,
|
||||||
matched: [],
|
useRouter: () => ({
|
||||||
|
push: mockRouterPush,
|
||||||
}),
|
}),
|
||||||
useRouter: () => ({}),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -86,6 +99,11 @@ function mountTopbar() {
|
|||||||
describe('AppTopbar', () => {
|
describe('AppTopbar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
mockRouterPush.mockReset()
|
||||||
|
mockUseRoute.mockReset()
|
||||||
|
|
||||||
|
// Default: empty matched array (no breadcrumb items)
|
||||||
|
mockUseRoute.mockReturnValue({ matched: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -221,4 +239,56 @@ describe('AppTopbar', () => {
|
|||||||
|
|
||||||
expect(logoutSpy).toHaveBeenCalledOnce()
|
expect(logoutSpy).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// FIX A regression: breadcrumb model mapping uses command+router.push,
|
||||||
|
// NOT the `route` key that this PrimeVue Breadcrumb version ignores.
|
||||||
|
//
|
||||||
|
// BreadcrumbItem.vue renders <a :href="item.url || '#'"> and calls
|
||||||
|
// item.command on click — it never reads `route`. This test would have
|
||||||
|
// caught the broken mapping that set `route` instead of `command`.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('breadcrumb model: non-last items have command that calls router.push; last item has no command and no route key', async () => {
|
||||||
|
// Provide two matched records so we get a non-last item (with `to`) and
|
||||||
|
// a last item (current, no `to`).
|
||||||
|
mockUseRoute.mockReturnValue({
|
||||||
|
matched: [
|
||||||
|
{ meta: { breadcrumb: 'Dashboard' }, name: 'dashboard', path: '/dashboard' },
|
||||||
|
{ meta: { breadcrumb: 'Events' }, name: 'events', path: '/events' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mountTopbar()
|
||||||
|
|
||||||
|
const breadcrumbStub = wrapper.findComponent({ name: 'Breadcrumb' })
|
||||||
|
|
||||||
|
expect(breadcrumbStub.exists()).toBe(true)
|
||||||
|
|
||||||
|
const model = breadcrumbStub.props('model') as Array<{
|
||||||
|
label: string
|
||||||
|
command?: () => void
|
||||||
|
route?: unknown
|
||||||
|
}>
|
||||||
|
|
||||||
|
// Should have two items produced by toBreadcrumbItems
|
||||||
|
expect(model).toHaveLength(2)
|
||||||
|
|
||||||
|
const [firstItem, lastItem] = model as [typeof model[0], typeof model[0]]
|
||||||
|
|
||||||
|
// Non-last item: must carry `command`, must NOT carry `route`
|
||||||
|
expect(firstItem.label).toBe('Dashboard')
|
||||||
|
expect(typeof firstItem.command).toBe('function')
|
||||||
|
expect('route' in firstItem).toBe(false)
|
||||||
|
|
||||||
|
// Invoking command must call router.push with the item's resolved `to` path
|
||||||
|
firstItem.command!()
|
||||||
|
expect(mockRouterPush).toHaveBeenCalledOnce()
|
||||||
|
expect(mockRouterPush).toHaveBeenCalledWith('/dashboard')
|
||||||
|
|
||||||
|
// Last/current item: must have NO command (non-interactive), NO `route` key
|
||||||
|
expect(lastItem.label).toBe('Events')
|
||||||
|
expect(lastItem.command).toBeUndefined()
|
||||||
|
expect('route' in lastItem).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ export function toBreadcrumbItems(matched: readonly BreadcrumbRouteRecord[]): Br
|
|||||||
if (isLast)
|
if (isLast)
|
||||||
return { label }
|
return { label }
|
||||||
|
|
||||||
// Prefer path; fall back to name when path is absent/empty
|
// Prefer path; fall back to name when path is absent/empty.
|
||||||
|
// NOTE: a non-last record whose `path` is a param template (e.g. `/events/:id`)
|
||||||
|
// would yield an unresolved-template `to` — acceptable in foundation scope because
|
||||||
|
// param routes are normally the last (current, no-`to`) segment.
|
||||||
|
// TODO TECH-WS-GUI-REDESIGN: resolve param paths if a non-leaf param route ever needs a crumb link
|
||||||
const to: RouteLocationRaw
|
const to: RouteLocationRaw
|
||||||
= record.path
|
= record.path
|
||||||
? record.path
|
? record.path
|
||||||
|
|||||||
Reference in New Issue
Block a user