Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 48 additions & 23 deletions apps/frontend/src/components/AppBreadcrumb.vue
Original file line number Diff line number Diff line change
@@ -1,44 +1,69 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import router from '@/router'
import { useI18n } from '@/i18n'
import { computed } from 'vue'
import { useRoute, useRouter, type RouteLocationNormalizedLoaded } from 'vue-router'
import { localized, useI18n } from '@/i18n'
import { findModule } from '@/modules/manifest'

const { t } = useI18n()
const route = useRoute()
const router = useRouter()

interface Breadcrumb {
active: boolean
name: string | symbol | undefined
path: string
label: string
href: string
}

const breadcrumbs = ref<Breadcrumb[]>([])

const getBreadcrumbs = (): Breadcrumb[] => {
return router.currentRoute.value.matched.map((route) => ({
active: route.path === router.currentRoute.value.fullPath,
name: (route.meta.titleKey as string | undefined) ?? route.name,
path: `${router.options.history.base}${route.path}`,
}))
/**
* Crumbs for the current location (JUM-906).
*
* `route.matched` holds route records, whose `path` is the pattern — a module
* page rendered "Home / Home" linking to `/#/m/:moduleId/:tab?`. Each crumb now
* links to a resolved location, and a module route shows the module and the
* active tab by name.
*/
const buildBreadcrumbs = (current: RouteLocationNormalizedLoaded): Breadcrumb[] => {
const crumbs: Breadcrumb[] = []
for (const record of current.matched) {
if (record.name === 'Module') {
const moduleId = String(current.params.moduleId ?? '')
const manifest = findModule(moduleId)
crumbs.push({
active: false,
label: manifest ? localized(manifest.title) : moduleId,
href: router.resolve({ name: 'Module', params: { moduleId } }).href
})
const tab = typeof current.params.tab === 'string' ? current.params.tab : ''
const entity = manifest?.entities.find((item) => item.id === tab)
const tabLabel = entity ? localized(entity.title) : tab === 'dashboard' ? t('nav.dashboard') : ''
if (tabLabel) crumbs.push({ active: false, label: tabLabel, href: router.resolve(current.fullPath).href })
continue
}
const key = record.meta.titleKey as string | undefined
crumbs.push({
active: false,
label: key ? t(key) : String(record.name ?? ''),
href: record.name
? router.resolve({ name: record.name, params: record.path.includes(':') ? current.params : {} }).href
: router.resolve(current.fullPath).href
})
}
if (crumbs.length > 0) crumbs[crumbs.length - 1].active = true
return crumbs
}

router.afterEach(() => {
breadcrumbs.value = getBreadcrumbs()
})

onMounted(() => {
breadcrumbs.value = getBreadcrumbs()
})
const breadcrumbs = computed(() => buildBreadcrumbs(route))
</script>

<template>
<CBreadcrumb class="my-0">
<CBreadcrumbItem
v-for="item in breadcrumbs"
:key="item.path"
:href="item.active ? '' : item.path"
:key="item.href + item.label"
:href="item.active ? '' : item.href"
:active="item.active"
>
{{ typeof item.name === 'string' && item.name.startsWith('nav.') ? t(item.name) : String(item.name ?? '') }}
{{ item.label }}
</CBreadcrumbItem>
</CBreadcrumb>
</template>
2 changes: 1 addition & 1 deletion apps/frontend/src/data/canaSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const storeNameFromListPath = (operationId: string, schemaName: string): string
for (const [path, methods] of Object.entries(document.paths ?? {})) {
for (const operation of Object.values(methods)) {
if (operation?.operationId === operationId) {
const segment = path.split('/').filter(Boolean).findLast(Boolean);
const segment = path.split('/').filter(Boolean).at(-1);
if (segment) return segment;
}
}
Expand Down
64 changes: 64 additions & 0 deletions apps/frontend/test/component/AppBreadcrumb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
afterEach, describe, expect, it
} from 'bun:test';
import { createMemoryHistory, createRouter } from 'vue-router';

import AppBreadcrumb from '@/components/AppBreadcrumb.vue';
import '@/modules/index';

import { flush, freshSession, mountWithShell } from './support';

/**
* JUM-906: the breadcrumb read `route.matched[].path`, which is the route
* pattern — a module page showed "Home / Home" and linked to
* `/#/m/:moduleId/:tab?`.
*/
const routes = [
{
path: '/',
name: 'Home',
component: { template: '<router-view />' },
meta: { titleKey: 'nav.home' },
children: [
{
path: '/m/:moduleId/:tab?', name: 'Module', component: { template: '<div />' }, meta: { titleKey: 'nav.home' }
}
]
}
];

async function crumbsAt(path: string) {
const pinia = freshSession();
const router = createRouter({ history: createMemoryHistory(), routes });
await router.push(path);
const wrapper = mountWithShell(AppBreadcrumb, { pinia, global: { plugins: [pinia, router] } });
await flush(2);
const items = wrapper.findAll('.breadcrumb-item').map((item) => ({
text: item.text(),
href: item.find('a').exists() ? item.find('a').attributes('href') : ''
}));
wrapper.unmount();
return items;
}

describe('AppBreadcrumb (JUM-906)', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('names the module and the active tab instead of repeating Home', async () => {
expect.hasAssertions();
const items = await crumbsAt('/m/users/dashboard');

expect(items.map((item) => item.text)).toStrictEqual(['Home', 'Users', 'Dashboard']);
});

it('links to resolved locations, never to a route pattern', async () => {
expect.hasAssertions();
const items = await crumbsAt('/m/users/organizations');

expect(items.map((item) => item.text)).toStrictEqual(['Home', 'Users', 'Organizations']);
expect(items.some((item) => item.href.includes(':moduleId'))).toBe(false);
expect(items[1].href).toBe('/m/users');
});
});
6 changes: 3 additions & 3 deletions packages/cli-init/templates.manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"sourceCommit": "d038d9ebf8b2d0e234ea7c12c6336f356d4b1595",
"sourceCommit": "2a9a981e16853b7596716e505844f22611d2b506",
"exclusions": [
"**/.agents/**",
"**/.claude/**",
Expand Down Expand Up @@ -3788,7 +3788,7 @@
"source": "apps/frontend/src/App.vue"
},
"frontend/src/components/AppBreadcrumb.vue": {
"sha256": "6f3b663e86a6e799b1e424336424f0bf81101e2d6e9c528c39c0e464d910244c",
"sha256": "f47862797e201e7c32a01047baa06c52e6574a32e06c824e7924867c8d9b9dff",
"source": "apps/frontend/src/components/AppBreadcrumb.vue"
},
"frontend/src/components/AppFooter.vue": {
Expand Down Expand Up @@ -3972,7 +3972,7 @@
"source": "apps/frontend/src/contracts/validation.ts"
},
"frontend/src/data/canaSchema.ts": {
"sha256": "358e8e3e26e9b6266b89d7ef568941018d4d5ba19255827cf153cc61605e05d0",
"sha256": "1d34048e3b810346d445ea334fb03ac7391521c4fe6149391e278cfddd37b2d9",
"source": "apps/frontend/src/data/canaSchema.ts"
},
"frontend/src/data/db.ts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,69 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import router from '@/router'
import { useI18n } from '@/i18n'
import { computed } from 'vue'
import { useRoute, useRouter, type RouteLocationNormalizedLoaded } from 'vue-router'
import { localized, useI18n } from '@/i18n'
import { findModule } from '@/modules/manifest'

const { t } = useI18n()
const route = useRoute()
const router = useRouter()

interface Breadcrumb {
active: boolean
name: string | symbol | undefined
path: string
label: string
href: string
}

const breadcrumbs = ref<Breadcrumb[]>([])

const getBreadcrumbs = (): Breadcrumb[] => {
return router.currentRoute.value.matched.map((route) => ({
active: route.path === router.currentRoute.value.fullPath,
name: (route.meta.titleKey as string | undefined) ?? route.name,
path: `${router.options.history.base}${route.path}`,
}))
/**
* Crumbs for the current location (JUM-906).
*
* `route.matched` holds route records, whose `path` is the pattern — a module
* page rendered "Home / Home" linking to `/#/m/:moduleId/:tab?`. Each crumb now
* links to a resolved location, and a module route shows the module and the
* active tab by name.
*/
const buildBreadcrumbs = (current: RouteLocationNormalizedLoaded): Breadcrumb[] => {
const crumbs: Breadcrumb[] = []
for (const record of current.matched) {
if (record.name === 'Module') {
const moduleId = String(current.params.moduleId ?? '')
const manifest = findModule(moduleId)
crumbs.push({
active: false,
label: manifest ? localized(manifest.title) : moduleId,
href: router.resolve({ name: 'Module', params: { moduleId } }).href
})
const tab = typeof current.params.tab === 'string' ? current.params.tab : ''
const entity = manifest?.entities.find((item) => item.id === tab)
const tabLabel = entity ? localized(entity.title) : tab === 'dashboard' ? t('nav.dashboard') : ''
if (tabLabel) crumbs.push({ active: false, label: tabLabel, href: router.resolve(current.fullPath).href })
continue
}
const key = record.meta.titleKey as string | undefined
crumbs.push({
active: false,
label: key ? t(key) : String(record.name ?? ''),
href: record.name
? router.resolve({ name: record.name, params: record.path.includes(':') ? current.params : {} }).href
: router.resolve(current.fullPath).href
})
}
if (crumbs.length > 0) crumbs[crumbs.length - 1].active = true
return crumbs
}

router.afterEach(() => {
breadcrumbs.value = getBreadcrumbs()
})

onMounted(() => {
breadcrumbs.value = getBreadcrumbs()
})
const breadcrumbs = computed(() => buildBreadcrumbs(route))
</script>

<template>
<CBreadcrumb class="my-0">
<CBreadcrumbItem
v-for="item in breadcrumbs"
:key="item.path"
:href="item.active ? '' : item.path"
:key="item.href + item.label"
:href="item.active ? '' : item.href"
:active="item.active"
>
{{ typeof item.name === 'string' && item.name.startsWith('nav.') ? t(item.name) : String(item.name ?? '') }}
{{ item.label }}
</CBreadcrumbItem>
</CBreadcrumb>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const storeNameFromListPath = (operationId: string, schemaName: string): string
for (const [path, methods] of Object.entries(document.paths ?? {})) {
for (const operation of Object.values(methods)) {
if (operation?.operationId === operationId) {
const segment = path.split('/').filter(Boolean).findLast(Boolean);
const segment = path.split('/').filter(Boolean).at(-1);
if (segment) return segment;
}
}
Expand Down
12 changes: 12 additions & 0 deletions test-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -2661,6 +2661,18 @@
"tier": "gate",
"timeoutMs": 60000
},
{
"id": "apps/frontend/test/component/AppBreadcrumb.test.ts",
"path": "apps/frontend/test/component/AppBreadcrumb.test.ts",
"layer": "frontend",
"kind": "non-hexagonal",
"type": "unit",
"runner": "bun",
"script": "frontend:test:unit",
"reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.",
"tier": "gate",
"timeoutMs": 60000
},
{
"id": "apps/frontend/test/component/ModuleLayout.test.ts",
"path": "apps/frontend/test/component/ModuleLayout.test.ts",
Expand Down
Loading