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"
|
:expandable="folder.is_dir"
|
||||||
:load-children="loadChildren"
|
:load-children="loadChildren"
|
||||||
:depth="depth"
|
:depth="depth"
|
||||||
|
:is-active="isActive"
|
||||||
@select="navigate"
|
@select="navigate"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
v-for="child in children"
|
v-for="child in children"
|
||||||
:key="child.id"
|
:key="child.id"
|
||||||
:folder="child"
|
:folder="child"
|
||||||
:provider="provider"
|
:connection-id="connectionId"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,25 +24,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 * as api from '../../api/client.js'
|
||||||
import AppIcon from '../ui/AppIcon.vue'
|
import AppIcon from '../ui/AppIcon.vue'
|
||||||
import TreeItem from '../ui/TreeItem.vue'
|
import TreeItem from '../ui/TreeItem.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
folder: { type: Object, required: true },
|
folder: { type: Object, required: true },
|
||||||
provider: { type: String, required: true },
|
connectionId: { type: String, required: true },
|
||||||
depth: { type: Number, default: 2 },
|
depth: { type: Number, default: 2 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
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() {
|
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)
|
return (data.items ?? []).filter(i => i.is_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate() {
|
function navigate() {
|
||||||
router.push(`/cloud/${props.provider}/${props.folder.id}`)
|
router.push(`/cloud/${props.connectionId}/${props.folder.id}`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<TreeItem
|
<TreeItem
|
||||||
:label="connection.display_name"
|
:label="connectionName"
|
||||||
:load-children="loadChildren"
|
:load-children="loadChildren"
|
||||||
:depth="depth"
|
:depth="depth"
|
||||||
|
:is-active="isActive"
|
||||||
@select="navigateToRoot"
|
@select="navigateToRoot"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
v-for="folder in children"
|
v-for="folder in children"
|
||||||
:key="folder.id"
|
:key="folder.id"
|
||||||
:folder="folder"
|
:folder="folder"
|
||||||
:provider="connection.provider"
|
:connection-id="connection.id"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
import AppIcon from '../ui/AppIcon.vue'
|
import AppIcon from '../ui/AppIcon.vue'
|
||||||
import TreeItem from '../ui/TreeItem.vue'
|
import TreeItem from '../ui/TreeItem.vue'
|
||||||
@@ -35,15 +36,30 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const providerIconColor = computed(() => providerColor(props.connection.provider))
|
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() {
|
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)
|
return (data.items ?? []).filter(i => i.is_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToRoot() {
|
function navigateToRoot() {
|
||||||
router.push(`/cloud/${props.connection.provider}/root`)
|
router.push(`/cloud/${props.connection.id}/root`)
|
||||||
}
|
}
|
||||||
</script>
|
</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
|
<router-link
|
||||||
to="/cloud"
|
to="/cloud"
|
||||||
class="nav-link flex-1 min-w-0"
|
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" />
|
<AppIcon name="cloud" class="w-4 h-4 mr-2 shrink-0 text-sky-500" />
|
||||||
Cloud Storage
|
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