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