feat(12-06): UUID-only cloud navigation, breadcrumb hierarchy, route-aware sidebar

- CloudProviderTreeItem: getCloudFoldersByConnectionId(connection.id) + /cloud/{uuid}/root
- CloudFolderTreeItem: connectionId prop replaces provider slug in browse + route
- AppSidebar: Cloud Storage active only on exact /cloud; connection nodes use per-connection isActive
- New tests: CloudProviderTreeItem, CloudFolderTreeItem, AppSidebar cloud active-state
This commit is contained in:
curo1305
2026-06-21 22:30:05 +02:00
parent 731b65ecdd
commit 057b4999fd
6 changed files with 286 additions and 11 deletions
@@ -4,6 +4,7 @@
:expandable="folder.is_dir"
:load-children="loadChildren"
:depth="depth"
:is-active="isActive"
@select="navigate"
>
<template #icon>
@@ -15,7 +16,7 @@
v-for="child in children"
:key="child.id"
:folder="child"
:provider="provider"
:connection-id="connectionId"
:depth="depth + 1"
/>
</template>
@@ -23,25 +24,33 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import * as api from '../../api/client.js'
import AppIcon from '../ui/AppIcon.vue'
import TreeItem from '../ui/TreeItem.vue'
const props = defineProps({
folder: { type: Object, required: true },
provider: { type: String, required: true },
connectionId: { type: String, required: true },
depth: { type: Number, default: 2 },
})
const router = useRouter()
const route = useRoute()
/** Active when the current route points to this exact folder. */
const isActive = computed(() => {
return route.path === `/cloud/${props.connectionId}/${props.folder.id}`
})
async function loadChildren() {
const data = await api.getCloudFolders(props.provider, props.folder.id)
// Browse by connection UUID + provider item/path reference — never provider slug
const data = await api.getCloudFoldersByConnectionId(props.connectionId, props.folder.id)
return (data.items ?? []).filter(i => i.is_dir)
}
function navigate() {
router.push(`/cloud/${props.provider}/${props.folder.id}`)
router.push(`/cloud/${props.connectionId}/${props.folder.id}`)
}
</script>
@@ -1,8 +1,9 @@
<template>
<TreeItem
:label="connection.display_name"
:label="connectionName"
:load-children="loadChildren"
:depth="depth"
:is-active="isActive"
@select="navigateToRoot"
>
<template #icon>
@@ -13,7 +14,7 @@
v-for="folder in children"
:key="folder.id"
:folder="folder"
:provider="connection.provider"
:connection-id="connection.id"
:depth="depth + 1"
/>
</template>
@@ -22,7 +23,7 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import * as api from '../../api/client.js'
import AppIcon from '../ui/AppIcon.vue'
import TreeItem from '../ui/TreeItem.vue'
@@ -35,15 +36,30 @@ const props = defineProps({
})
const router = useRouter()
const route = useRoute()
const providerIconColor = computed(() => providerColor(props.connection.provider))
const connectionName = computed(
() => props.connection.display_name || props.connection.provider
)
/** Active when the current route is this connection's root or any subfolder. */
const isActive = computed(() => {
const path = route.path
return (
path === `/cloud/${props.connection.id}/root` ||
path.startsWith(`/cloud/${props.connection.id}/`)
)
})
async function loadChildren() {
const data = await api.getCloudFolders(props.connection.provider, 'root')
// Use connection UUID, never provider slug (UAT gap: 422 from UUID endpoint if slug passed)
const data = await api.getCloudFoldersByConnectionId(props.connection.id, '')
return (data.items ?? []).filter(i => i.is_dir)
}
function navigateToRoot() {
router.push(`/cloud/${props.connection.provider}/root`)
router.push(`/cloud/${props.connection.id}/root`)
}
</script>
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const mockPush = vi.fn()
let mockRoutePath = '/cloud/uuid-conn-1/some-folder'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
useRoute: () => ({ path: mockRoutePath }),
}))
vi.mock('../../../api/client.js', () => ({
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [] }),
getCloudFolders: vi.fn(), // deprecated — must NOT be called
}))
import * as api from '../../../api/client.js'
import CloudFolderTreeItem from '../CloudFolderTreeItem.vue'
const TreeItemStub = {
name: 'TreeItem',
template: `<div data-test="tree-item" @click="$emit('select')"><slot name="children" :children="[]" /></div>`,
props: ['label', 'loadChildren', 'depth', 'isActive', 'expandable'],
emits: ['select'],
}
const globalStubs = {
TreeItem: TreeItemStub,
AppIcon: true,
}
const folder = { id: 'some-folder', name: 'Documents', is_dir: true }
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockRoutePath = '/cloud/uuid-conn-1/some-folder'
})
describe('CloudFolderTreeItem', () => {
it('navigates to /cloud/{connectionId}/{folderId}, never /cloud/{provider}/{folderId}', async () => {
const wrapper = mount(CloudFolderTreeItem, {
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
global: { stubs: globalStubs },
})
await wrapper.find('[data-test="tree-item"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/cloud/uuid-conn-1/some-folder')
expect(mockPush).not.toHaveBeenCalledWith(expect.stringContaining('nextcloud'))
})
it('browses children using getCloudFoldersByConnectionId with folder.id', async () => {
const wrapper = mount(CloudFolderTreeItem, {
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
global: { stubs: globalStubs },
})
const treeItemStub = wrapper.findComponent(TreeItemStub)
const loadChildren = treeItemStub.props('loadChildren')
if (loadChildren) {
await loadChildren()
expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', 'some-folder')
}
expect(api.getCloudFolders).not.toHaveBeenCalled()
})
it('isActive=true when current route matches this folder', () => {
const wrapper = mount(CloudFolderTreeItem, {
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
global: { stubs: globalStubs },
})
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(true)
})
it('isActive=false when route does not match this folder', () => {
mockRoutePath = '/cloud/uuid-conn-1/other-folder'
const wrapper = mount(CloudFolderTreeItem, {
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
global: { stubs: globalStubs },
})
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(false)
})
})
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const mockPush = vi.fn()
let mockRoutePath = '/cloud'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
useRoute: () => ({ path: mockRoutePath }),
}))
vi.mock('../../../api/client.js', () => ({
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [] }),
getCloudFolders: vi.fn(), // deprecated — must NOT be called
}))
import * as api from '../../../api/client.js'
import CloudProviderTreeItem from '../CloudProviderTreeItem.vue'
const TreeItemStub = {
name: 'TreeItem',
template: `<div data-test="tree-item" @click="$emit('select')"><slot name="children" :children="[]" /></div>`,
props: ['label', 'loadChildren', 'depth', 'isActive'],
emits: ['select'],
}
const globalStubs = {
TreeItem: TreeItemStub,
CloudFolderTreeItem: true,
AppIcon: true,
}
const connection = {
id: 'uuid-conn-1',
provider: 'nextcloud',
display_name: 'My Nextcloud',
status: 'ACTIVE',
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockRoutePath = '/cloud'
})
describe('CloudProviderTreeItem', () => {
it('uses connection.id (UUID) for browse, never provider slug', async () => {
const wrapper = mount(CloudProviderTreeItem, {
props: { connection },
global: { stubs: globalStubs },
})
// Call loadChildren from the stub's prop
const treeItemStub = wrapper.findComponent(TreeItemStub)
const loadChildren = treeItemStub.props('loadChildren')
if (loadChildren) {
await loadChildren()
expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', '')
expect(api.getCloudFoldersByConnectionId).not.toHaveBeenCalledWith('nextcloud', expect.anything())
}
// getCloudFolders (deprecated) must never be called
expect(api.getCloudFolders).not.toHaveBeenCalled()
})
it('navigates to /cloud/{uuid}/root on select, never /cloud/{provider}', async () => {
const wrapper = mount(CloudProviderTreeItem, {
props: { connection },
global: { stubs: globalStubs },
})
await wrapper.find('[data-test="tree-item"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/cloud/uuid-conn-1/root')
expect(mockPush).not.toHaveBeenCalledWith(expect.stringContaining('nextcloud'))
})
it('isActive=true when route matches connection root', () => {
mockRoutePath = '/cloud/uuid-conn-1/root'
const wrapper = mount(CloudProviderTreeItem, {
props: { connection },
global: { stubs: globalStubs },
})
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(true)
})
it('isActive=false when route is /cloud (overview)', () => {
mockRoutePath = '/cloud'
const wrapper = mount(CloudProviderTreeItem, {
props: { connection },
global: { stubs: globalStubs },
})
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(false)
})
})
@@ -96,7 +96,7 @@
<router-link
to="/cloud"
class="nav-link flex-1 min-w-0"
:class="{ 'nav-link-active': $route.path.startsWith('/cloud') }"
:class="{ 'nav-link-active': $route.path === '/cloud' }"
>
<AppIcon name="cloud" class="w-4 h-4 mr-2 shrink-0 text-sky-500" />
Cloud Storage
@@ -0,0 +1,76 @@
/**
* AppSidebar cloud active-state tests.
*
* Verifies that the "Cloud Storage" nav link is active only when the route
* is exactly /cloud (the overview), NOT when a connection or folder is active.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
vi.mock('../../../api/client.js', () => ({
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
const STUBS = {
FolderTreeItem: true,
CloudProviderTreeItem: true,
QuotaBar: true,
EmptyState: true,
}
function makeRouter(initialPath = '/cloud') {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div/>' } },
{ path: '/settings', component: { template: '<div/>' } },
{ path: '/topics', component: { template: '<div/>' } },
{ path: '/shared', component: { template: '<div/>' } },
{ path: '/cloud', component: { template: '<div/>' } },
{ path: '/cloud/:connectionId/:folderId', component: { template: '<div/>' } },
],
})
router.push(initialPath)
return router
}
import AppSidebar from '../AppSidebar.vue'
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('AppSidebar cloud active state', () => {
it('Cloud Storage link has nav-link-active class at /cloud (exact)', async () => {
const router = makeRouter('/cloud')
await router.isReady()
const wrapper = mount(AppSidebar, {
global: { plugins: [router], stubs: STUBS },
})
await flushPromises()
// Find the Cloud Storage router-link — it should be active on /cloud
const cloudLink = wrapper.findAll('a').find(a => a.text().includes('Cloud Storage'))
expect(cloudLink).toBeTruthy()
expect(cloudLink.classes()).toContain('nav-link-active')
})
it('Cloud Storage link does NOT have nav-link-active when inside a connection folder', async () => {
const router = makeRouter('/cloud/uuid-conn-1/root')
await router.isReady()
const wrapper = mount(AppSidebar, {
global: { plugins: [router], stubs: STUBS },
})
await flushPromises()
const cloudLink = wrapper.findAll('a').find(a => a.text().includes('Cloud Storage'))
expect(cloudLink).toBeTruthy()
// Must NOT be active — only the connection/folder node is active
expect(cloudLink.classes()).not.toContain('nav-link-active')
})
})