feat(12-03): connection-ID routing, rename, session folder memory, thin views
- Route /cloud/:connectionId/:folderId replaces /cloud/:provider/:folderId - api/cloud.js: getCloudFoldersByConnectionId + renameCloudConnection - cloudConnections store: rename, selectConnection, setBrowseState, defaultDisplayName, session storage helpers - CloudStorageView: thin — passes connectionRoots to StorageBrowser - CloudFolderView: thin — uses connectionId param, never provider slug - SettingsCloudTab: inline rename input for each active connection - 27 tests pass: store, view, settings
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock api/client.js — no real HTTP calls in unit tests (CLAUDE.md W4)
|
||||
@@ -7,14 +7,22 @@ vi.mock('../../api/client.js', () => ({
|
||||
disconnectCloud: vi.fn(),
|
||||
connectWebDav: vi.fn(),
|
||||
updateDefaultStorage: vi.fn(),
|
||||
renameCloudConnection: vi.fn(),
|
||||
getCloudFoldersByConnectionId: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useCloudConnectionsStore } from '../cloudConnections.js'
|
||||
import { useCloudConnectionsStore, saveLastFolder, loadLastFolder } from '../cloudConnections.js'
|
||||
import * as api from '../../api/client.js'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
// Clear sessionStorage between tests
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe('useCloudConnectionsStore', () => {
|
||||
@@ -56,4 +64,95 @@ describe('useCloudConnectionsStore', () => {
|
||||
await store.disconnectAll()
|
||||
expect(store.connections).toHaveLength(0)
|
||||
})
|
||||
|
||||
// Rename tests
|
||||
it('rename updates the connection in state on success', async () => {
|
||||
api.renameCloudConnection.mockResolvedValue({
|
||||
id: 'conn-1', provider: 'google_drive', display_name: 'My Drive',
|
||||
})
|
||||
const store = useCloudConnectionsStore()
|
||||
store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }]
|
||||
await store.rename('conn-1', 'My Drive')
|
||||
expect(store.connections[0].display_name).toBe('My Drive')
|
||||
expect(api.renameCloudConnection).toHaveBeenCalledWith('conn-1', 'My Drive')
|
||||
})
|
||||
|
||||
it('rename rejects on empty display name', async () => {
|
||||
const store = useCloudConnectionsStore()
|
||||
await expect(store.rename('conn-1', '')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rename rejects on whitespace-only display name', async () => {
|
||||
const store = useCloudConnectionsStore()
|
||||
await expect(store.rename('conn-1', ' ')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rename propagates API error', async () => {
|
||||
api.renameCloudConnection.mockRejectedValue(new Error('API error'))
|
||||
const store = useCloudConnectionsStore()
|
||||
store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }]
|
||||
await expect(store.rename('conn-1', 'New Name')).rejects.toThrow('API error')
|
||||
})
|
||||
|
||||
// Two same-provider connections render as separate roots
|
||||
it('two google_drive connections render as separate roots with UUID suffixes in defaultDisplayName', () => {
|
||||
const store = useCloudConnectionsStore()
|
||||
store.connections = [
|
||||
{ id: 'aabb-ccdd-1', provider: 'google_drive', display_name: null },
|
||||
{ id: 'eeff-gggg-2', provider: 'google_drive', display_name: null },
|
||||
]
|
||||
const nameA = store.defaultDisplayName(store.connections[0])
|
||||
const nameB = store.defaultDisplayName(store.connections[1])
|
||||
// Both should contain "Google Drive" and a 4-char suffix
|
||||
expect(nameA).toContain('Google Drive')
|
||||
expect(nameB).toContain('Google Drive')
|
||||
expect(nameA).toContain('aabb')
|
||||
expect(nameB).toContain('eeff')
|
||||
})
|
||||
|
||||
it('single connection has no suffix in defaultDisplayName', () => {
|
||||
const store = useCloudConnectionsStore()
|
||||
store.connections = [{ id: 'conn-1', provider: 'google_drive' }]
|
||||
expect(store.defaultDisplayName(store.connections[0])).toBe('Google Drive')
|
||||
})
|
||||
|
||||
// Connection UUID based URLs
|
||||
it('selectConnection sets selectedConnectionId', () => {
|
||||
const store = useCloudConnectionsStore()
|
||||
store.connections = [{ id: 'uuid-123', provider: 'onedrive' }]
|
||||
store.selectConnection('uuid-123')
|
||||
expect(store.selectedConnectionId).toBe('uuid-123')
|
||||
expect(store.selectedConnection?.id).toBe('uuid-123')
|
||||
})
|
||||
|
||||
// Session storage for folder navigation
|
||||
it('saveLastFolder stores folder in sessionStorage', () => {
|
||||
saveLastFolder('conn-abc', 'Documents/Work')
|
||||
expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBe('Documents/Work')
|
||||
})
|
||||
|
||||
it('loadLastFolder retrieves stored folder', () => {
|
||||
sessionStorage.setItem('docuvault:cloud:folder:conn-xyz', 'Photos')
|
||||
expect(loadLastFolder('conn-xyz')).toBe('Photos')
|
||||
})
|
||||
|
||||
it('saveLastFolder removes key for root folder', () => {
|
||||
sessionStorage.setItem('docuvault:cloud:folder:conn-abc', 'Docs')
|
||||
saveLastFolder('conn-abc', 'root')
|
||||
expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('loadLastFolder returns null when nothing stored', () => {
|
||||
expect(loadLastFolder('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
it('sessionStorage stores only folder references, not tokens', () => {
|
||||
// Ensure save only stores the folderId string under the namespaced key
|
||||
saveLastFolder('conn-123', 'Projects/Secret')
|
||||
const stored = sessionStorage.getItem('docuvault:cloud:folder:conn-123')
|
||||
expect(stored).toBe('Projects/Secret')
|
||||
// Stored value is a plain string path, not a JSON object with credentials
|
||||
expect(typeof stored).toBe('string')
|
||||
expect(stored.startsWith('{')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,57 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from '../api/client.js'
|
||||
|
||||
/**
|
||||
* Session-storage key for last visited folder per connection.
|
||||
* Only folder navigation state is stored — never tokens or credentials.
|
||||
*/
|
||||
function folderSessionKey(connectionId) {
|
||||
return `docuvault:cloud:folder:${connectionId}`
|
||||
}
|
||||
|
||||
export function saveLastFolder(connectionId, folderId) {
|
||||
try {
|
||||
if (folderId && folderId !== 'root') {
|
||||
sessionStorage.setItem(folderSessionKey(connectionId), folderId)
|
||||
} else {
|
||||
sessionStorage.removeItem(folderSessionKey(connectionId))
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function loadLastFolder(connectionId) {
|
||||
try {
|
||||
return sessionStorage.getItem(folderSessionKey(connectionId)) || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
|
||||
const connections = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Active browse state (populated by CloudFolderView)
|
||||
const selectedConnectionId = ref(null)
|
||||
const browseItems = ref([])
|
||||
const browseLoading = ref(false)
|
||||
const browseError = ref(null)
|
||||
/** Capabilities for the currently selected connection */
|
||||
const capabilities = ref(null)
|
||||
/** 'refreshing' | 'fresh' | 'stale' | null */
|
||||
const folderFreshness = ref(null)
|
||||
const lastRefreshedAt = ref(null)
|
||||
/** 'cached' | 'cloud_only' | null */
|
||||
const byteAvailability = ref(null)
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
connections.value.find(c => c.id === selectedConnectionId.value) ?? null
|
||||
)
|
||||
|
||||
async function fetchConnections() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -24,6 +69,9 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
|
||||
try {
|
||||
await api.disconnectCloud(id)
|
||||
connections.value = connections.value.filter(c => c.id !== id)
|
||||
if (selectedConnectionId.value === id) {
|
||||
selectedConnectionId.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
@@ -35,5 +83,79 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
|
||||
connections.value = []
|
||||
}
|
||||
|
||||
return { connections, loading, error, fetchConnections, disconnect, disconnectAll }
|
||||
/**
|
||||
* Rename a connection's display_name via the API.
|
||||
* Only updates the single returned connection in state.
|
||||
*/
|
||||
async function rename(id, displayName) {
|
||||
const trimmed = displayName?.trim()
|
||||
if (!trimmed) throw new Error('Display name cannot be empty')
|
||||
const updated = await api.renameCloudConnection(id, trimmed)
|
||||
const idx = connections.value.findIndex(c => c.id === id)
|
||||
if (idx !== -1) {
|
||||
connections.value[idx] = { ...connections.value[idx], ...updated }
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Default display_name for a connection.
|
||||
* If another connection shares the same provider, append a 4-char UUID suffix.
|
||||
*/
|
||||
function defaultDisplayName(connection) {
|
||||
const providerLabel = {
|
||||
google_drive: 'Google Drive',
|
||||
onedrive: 'OneDrive',
|
||||
nextcloud: 'Nextcloud',
|
||||
webdav: 'WebDAV',
|
||||
}[connection.provider] ?? connection.provider
|
||||
const siblings = connections.value.filter(
|
||||
c => c.provider === connection.provider && c.id !== connection.id
|
||||
)
|
||||
if (siblings.length > 0) {
|
||||
return `${providerLabel} (${connection.id.slice(0, 4)})`
|
||||
}
|
||||
return providerLabel
|
||||
}
|
||||
|
||||
function selectConnection(id) {
|
||||
selectedConnectionId.value = id
|
||||
browseItems.value = []
|
||||
browseError.value = null
|
||||
capabilities.value = null
|
||||
folderFreshness.value = null
|
||||
lastRefreshedAt.value = null
|
||||
byteAvailability.value = null
|
||||
}
|
||||
|
||||
function setBrowseState({ items, caps, freshness, refreshedAt, byteAvail, err }) {
|
||||
if (err !== undefined) browseError.value = err
|
||||
if (items !== undefined) browseItems.value = items
|
||||
if (caps !== undefined) capabilities.value = caps
|
||||
if (freshness !== undefined) folderFreshness.value = freshness
|
||||
if (refreshedAt !== undefined) lastRefreshedAt.value = refreshedAt
|
||||
if (byteAvail !== undefined) byteAvailability.value = byteAvail
|
||||
}
|
||||
|
||||
return {
|
||||
connections,
|
||||
loading,
|
||||
error,
|
||||
selectedConnectionId,
|
||||
selectedConnection,
|
||||
browseItems,
|
||||
browseLoading,
|
||||
browseError,
|
||||
capabilities,
|
||||
folderFreshness,
|
||||
lastRefreshedAt,
|
||||
byteAvailability,
|
||||
fetchConnections,
|
||||
disconnect,
|
||||
disconnectAll,
|
||||
rename,
|
||||
defaultDisplayName,
|
||||
selectConnection,
|
||||
setBrowseState,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user