chore: merge executor worktree (worktree-agent-a280818c01e94acd2)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
---
|
||||
phase: "12"
|
||||
plan: "03"
|
||||
subsystem: cloud-ui-foundation
|
||||
tags: [cloud, routing, capabilities, breadcrumb, freshness, accessibility, session-storage]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "12-02" # connection-ID browse API, CloudResourceAdapter, cloud_items metadata
|
||||
provides:
|
||||
- connection-ID routing (/cloud/:connectionId/:folderId)
|
||||
- capability-aware action rendering (StorageBrowser)
|
||||
- breadcrumb freshness indicators (BreadcrumbBar)
|
||||
- session-only folder memory (cloudConnections store)
|
||||
- display-name rename for cloud connections
|
||||
affects:
|
||||
- frontend/src/api/cloud.js
|
||||
- frontend/src/stores/cloudConnections.js
|
||||
- frontend/src/router/index.js
|
||||
- frontend/src/views/CloudStorageView.vue
|
||||
- frontend/src/views/CloudFolderView.vue
|
||||
- frontend/src/components/settings/SettingsCloudTab.vue
|
||||
- frontend/src/components/storage/StorageBrowser.vue
|
||||
- frontend/src/components/ui/BreadcrumbBar.vue
|
||||
- frontend/src/components/ui/AppIcon.vue
|
||||
- frontend/src/utils/formatters.js
|
||||
- backend/main.py
|
||||
tech_stack:
|
||||
added:
|
||||
- CapabilityButton inline render-function component (StorageBrowser.vue)
|
||||
- formatRelativeTime (formatters.js)
|
||||
- saveLastFolder / loadLastFolder (cloudConnections.js session helpers)
|
||||
patterns:
|
||||
- capability-aware rendering (aria-disabled, not native disabled)
|
||||
- session-storage for navigation state only (never tokens/credentials)
|
||||
- connection UUID as routing identity (opaque, not provider slug)
|
||||
- immediate watcher for freshness state transitions
|
||||
key_files:
|
||||
created:
|
||||
- frontend/src/views/__tests__/CloudFolderView.test.js
|
||||
- frontend/src/views/__tests__/CloudStorageView.test.js
|
||||
- frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js
|
||||
modified:
|
||||
- frontend/src/api/cloud.js
|
||||
- frontend/src/stores/cloudConnections.js
|
||||
- frontend/src/stores/__tests__/cloudConnections.test.js
|
||||
- frontend/src/router/index.js
|
||||
- frontend/src/views/CloudStorageView.vue
|
||||
- frontend/src/views/CloudFolderView.vue
|
||||
- frontend/src/components/settings/SettingsCloudTab.vue
|
||||
- frontend/src/components/settings/__tests__/SettingsCloudTab.test.js
|
||||
- frontend/src/components/storage/StorageBrowser.vue
|
||||
- frontend/src/components/ui/BreadcrumbBar.vue
|
||||
- frontend/src/components/ui/__tests__/BreadcrumbBar.test.js
|
||||
- frontend/src/components/ui/AppIcon.vue
|
||||
- frontend/src/utils/formatters.js
|
||||
- backend/main.py
|
||||
- frontend/package.json
|
||||
- AGENTS.md
|
||||
- README.md
|
||||
decisions:
|
||||
- "Connection identity is the connection UUID, never a provider slug, in routes and API calls"
|
||||
- "CapabilityButton uses aria-disabled (not native disabled) so controls remain keyboard-focusable"
|
||||
- "Local mode uses full capability defaults — no mode==='local' gates remain in action rendering"
|
||||
- "session-only folder memory stored under namespaced key docuvault:cloud:folder:{uuid}"
|
||||
- "Fresh indicator fades after 3s using immediate watcher + fake-timer-tested setTimeout"
|
||||
- "formatRelativeTime added to formatters.js — not duplicated inline"
|
||||
metrics:
|
||||
duration: "~45 minutes"
|
||||
completed: "2026-06-18"
|
||||
tasks: 3
|
||||
files: 18
|
||||
---
|
||||
|
||||
# Phase 12 Plan 03: Cloud UI Foundation Summary
|
||||
|
||||
## One-liner
|
||||
|
||||
Connection-UUID routing, CapabilityButton-based action rendering, and BreadcrumbBar freshness indicators unify cloud and local file navigation under one shared StorageBrowser.
|
||||
|
||||
## What was built
|
||||
|
||||
### Task 1: Route and store cloud navigation by connection identity
|
||||
|
||||
- `api/cloud.js`: added `getCloudFoldersByConnectionId(connectionId, folderId)` and `renameCloudConnection(id, displayName)`; deprecated provider-keyed `getCloudFolders`
|
||||
- `cloudConnections.js` store: added `rename`, `selectConnection`, `setBrowseState`, `defaultDisplayName` (4-char UUID suffix for duplicate same-provider connections), and `saveLastFolder`/`loadLastFolder` session helpers
|
||||
- Router: `/cloud/:provider/:folderId` → `/cloud/:connectionId/:folderId`
|
||||
- `CloudStorageView`: thin data provider — maps connections to folder-like items and passes them to `StorageBrowser`; no parallel grid markup
|
||||
- `CloudFolderView`: thin data provider — uses `connectionId` param, calls `getCloudFoldersByConnectionId`, saves last folder in sessionStorage on navigate
|
||||
- `SettingsCloudTab`: added inline rename input per ACTIVE connection with Rename / Save / Cancel flow
|
||||
|
||||
### Task 2: Replace mode-gated actions with accessible capability rendering
|
||||
|
||||
- `StorageBrowser`: removed all `mode === 'local'` action visibility gates
|
||||
- Added props: `capabilities`, `connectionRoot`, `folderFreshness`, `lastRefreshedAt`, `byteAvailability`
|
||||
- `LOCAL_CAPS` constant provides full-supported defaults so local mode unchanged
|
||||
- `CapabilityButton` inline component: `aria-disabled="true"` (never native `disabled`), click/Enter/Space/touchend all suppressed for non-supported states; emits `explain` → `capability-explain` event and shows inline dismissible notice
|
||||
- Gray styling for `unsupported`; amber for `temporarily_unavailable`
|
||||
- Cached-byte marker (`clock` icon) on size column when `byteAvailability === 'cached'`; no marker for `cloud_only`
|
||||
- `AppIcon`: added `info` and `clock` icons
|
||||
|
||||
### Task 3: Breadcrumb freshness and plan protocol
|
||||
|
||||
- `BreadcrumbBar`: optional `folderFreshness` and `lastRefreshedAt` props; refreshing spinner (animated SVG), fresh checkmark (fades 3s via immediate watcher), persistent stale warning banner
|
||||
- All freshness states have `role="status"` and `aria-label` for screen readers
|
||||
- `formatters.js`: `formatRelativeTime` — human-readable relative time, no inline duplication
|
||||
- Version bumped 0.1.5 → 0.1.6 in `backend/main.py` and `frontend/package.json`
|
||||
- `AGENTS.md` and `README.md` updated with new state, shared module map, and feature text
|
||||
|
||||
## Test results
|
||||
|
||||
- 323 frontend tests pass (39 test files)
|
||||
- Production build: `✓ built in 510ms`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Threat surface scan
|
||||
|
||||
No new network endpoints introduced by this plan. Frontend adds:
|
||||
|
||||
| Flag | File | Description |
|
||||
|------|------|-------------|
|
||||
| threat_flag: sessionStorage-check | cloudConnections.js | `saveLastFolder` stores only folder path string under namespaced key; negative test asserts value is not `{` (not an object with credentials) |
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — StorageBrowser capability props are connected through CloudFolderView; default `LOCAL_CAPS` wires all local actions.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All key files found. All commits verified (bf9af11, 4424433, c6c0742). 323 tests pass. Production build clean.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV).
|
||||
|
||||
**Current state:** v0.1.5 — Phase 12 Plan 02 complete (2026-06-18). Connection-ID browse API (`GET /api/cloud/connections/{connection_id}/items`) with owner-scoped IDOR protection, stale-while-revalidate caching, and durable `refresh_cloud_folder` Celery task. All 4 providers implement `CloudResourceAdapter`. Not cleared for public internet deployment.
|
||||
**Current state:** v0.1.6 — Phase 12 Plan 03 complete (2026-06-18). Connection-ID routing, capability-aware action rendering, breadcrumb freshness indicators. Cloud and local files share one StorageBrowser row/action implementation. Not cleared for public internet deployment.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -59,7 +59,7 @@ Before adding a helper, check if it belongs in an existing shared module:
|
||||
|
||||
| Module | What lives here |
|
||||
|---|---|
|
||||
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `providerColor`, `providerBg`, `providerLabel` |
|
||||
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `formatRelativeTime`, `providerColor`, `providerBg`, `providerLabel` |
|
||||
| `src/components/ui/TreeItem.vue` | Generic expand/collapse tree node — all sidebar tree items wrap this |
|
||||
| `src/components/storage/StorageBrowser.vue` | Unified file browser grid — used by both `FileManagerView` and `CloudFolderView` |
|
||||
|
||||
@@ -123,9 +123,9 @@ This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts
|
||||
/gsd:progress — check status and advance workflow
|
||||
```
|
||||
|
||||
### Current state: v0.1.5 — Phase 12 Plan 02 complete (2026-06-18)
|
||||
### Current state: v0.1.6 — Phase 12 Plan 03 complete (2026-06-18)
|
||||
|
||||
Phase 12 Plan 02: connection-ID browse endpoint, `api/cloud/` package decomposition, `CloudResourceAdapter` mixin on all 4 providers, and `refresh_cloud_folder` Celery task with bounded retry. Stale-while-revalidate caching active. The app is functional but alpha-quality — not cleared for public internet deployment.
|
||||
Phase 12 Plan 03: connection-ID routing (`/cloud/:connectionId`), capability-aware action rendering in StorageBrowser, breadcrumb freshness indicators (refreshing/fresh/stale), session-only folder memory, and display-name rename for cloud connections. Cloud and local files share one row/action implementation via `CapabilityButton`. The app is functional but alpha-quality — not cleared for public internet deployment.
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DocuVault
|
||||
|
||||
**Version 0.1.5 — Alpha**
|
||||
**Version 0.1.6 — Alpha**
|
||||
|
||||
> **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared.
|
||||
|
||||
@@ -17,6 +17,8 @@ A self-hosted, multi-user document management platform with AI-powered topic cla
|
||||
- **Document sharing** — share by user handle with view or edit permission; "Shared with me" virtual folder; per-recipient revocation
|
||||
- **Storage quota** — per-user limit enforced atomically; amber/red quota bar at 80 % / 95 %; quota decremented on delete
|
||||
- **Cloud storage backends** — connect OneDrive, Google Drive, Nextcloud, or any WebDAV server as a personal storage backend; credentials encrypted with HKDF per-user keys
|
||||
- **Unified connection-root browsing** — each connected account appears as a distinct top-level root navigable by connection UUID; duplicate same-provider accounts are disambiguated automatically; local and cloud files share one row and action implementation
|
||||
- **Capability-aware actions** — unsupported cloud actions render gray with accessible explanations; temporarily unavailable actions render amber; all remain keyboard-focusable
|
||||
- **Auth** — email/password registration with HIBP breach check, optional TOTP 2FA, 8–10 backup codes, password reset by email, sign-out-all-devices
|
||||
- **Admin panel** — user CRUD, quota assignment, per-user AI provider override, AI provider system configuration, audit log viewer with CSV export
|
||||
- **Observability** — structured JSON logging via structlog, per-request correlation IDs, Loki + Promtail + Grafana stack included in Docker Compose
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# ── Application factory ───────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(title="Document Scanner API", version="0.1.5", lifespan=lifespan)
|
||||
app = FastAPI(title="Document Scanner API", version="0.1.6", lifespan=lifespan)
|
||||
|
||||
# Rate limiter state (slowapi)
|
||||
app.state.limiter = auth_limiter
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "document-scanner-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -29,10 +29,26 @@ export function updateDefaultStorage(backend) {
|
||||
return jsonRequest('/api/users/me/default-storage', 'PATCH', { backend })
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse a cloud folder by connection UUID and folder path.
|
||||
* connectionId is always a UUID; folderId is a path string (default 'root').
|
||||
*/
|
||||
export function getCloudFoldersByConnectionId(connectionId, folderId) {
|
||||
return request(`/api/cloud/connections/${connectionId}/folders/${folderId}`)
|
||||
}
|
||||
|
||||
/** @deprecated Use getCloudFoldersByConnectionId instead. */
|
||||
export function getCloudFolders(provider, folderId) {
|
||||
return request(`/api/cloud/folders/${provider}/${folderId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a cloud connection's display name.
|
||||
*/
|
||||
export function renameCloudConnection(id, displayName) {
|
||||
return jsonRequest(`/api/cloud/connections/${id}`, 'PATCH', { display_name: displayName })
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow for Google Drive or OneDrive.
|
||||
*
|
||||
|
||||
@@ -60,8 +60,33 @@
|
||||
|
||||
<!-- ACTIVE -->
|
||||
<template v-else-if="connectionFor(provider.key)?.status === 'ACTIVE'">
|
||||
<!-- Rename display name -->
|
||||
<template v-if="renamingId === connectionFor(provider.key)?.id">
|
||||
<input
|
||||
v-model="renameValue"
|
||||
class="border border-gray-300 rounded-lg px-2 py-1 text-sm w-40 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
@keydown.enter="submitRename(connectionFor(provider.key)?.id)"
|
||||
@keydown.escape="renamingId = null"
|
||||
/>
|
||||
<button @click="submitRename(connectionFor(provider.key)?.id)"
|
||||
class="text-sm px-2 py-1 text-indigo-600 hover:underline font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
||||
Save
|
||||
</button>
|
||||
<button @click="renamingId = null"
|
||||
class="text-sm px-2 py-1 text-gray-500 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
|
||||
v-else-if="confirmRemoveId !== connectionFor(provider.key)?.id"
|
||||
@click="startRename(connectionFor(provider.key))"
|
||||
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
title="Rename connection"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id && renamingId !== connectionFor(provider.key)?.id"
|
||||
@click="handleEdit(provider)"
|
||||
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
@@ -216,6 +241,9 @@ const editingConnection = ref(null)
|
||||
const confirmRemoveId = ref(null)
|
||||
const showDisconnectAll = ref(false)
|
||||
const oauthError = ref('')
|
||||
const renamingId = ref(null)
|
||||
const renameValue = ref('')
|
||||
const renameError = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchConnections()
|
||||
@@ -297,4 +325,22 @@ async function handleDisconnectAll() {
|
||||
async function handleConnected() {
|
||||
await store.fetchConnections()
|
||||
}
|
||||
|
||||
function startRename(connection) {
|
||||
if (!connection) return
|
||||
renamingId.value = connection.id
|
||||
renameValue.value = connection.display_name || ''
|
||||
renameError.value = ''
|
||||
}
|
||||
|
||||
async function submitRename(id) {
|
||||
if (!id) return
|
||||
renameError.value = ''
|
||||
try {
|
||||
await store.rename(id, renameValue.value)
|
||||
renamingId.value = null
|
||||
} catch (e) {
|
||||
renameError.value = e.message || 'Failed to rename connection'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockRename = vi.fn()
|
||||
|
||||
// Mock store module before importing component (W4 — CLAUDE.md unit test requirement)
|
||||
vi.mock('../../../stores/cloudConnections.js', () => ({
|
||||
useCloudConnectionsStore: () => ({
|
||||
@@ -11,6 +13,8 @@ vi.mock('../../../stores/cloudConnections.js', () => ({
|
||||
fetchConnections: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
disconnectAll: vi.fn(),
|
||||
rename: mockRename,
|
||||
defaultDisplayName: (c) => c.provider,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -19,6 +23,8 @@ vi.mock('../../../api/client.js', () => ({
|
||||
connectWebDav: vi.fn(),
|
||||
listCloudConnections: vi.fn(),
|
||||
disconnectCloud: vi.fn(),
|
||||
renameCloudConnection: vi.fn(),
|
||||
initiateOAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
import SettingsCloudTab from '../SettingsCloudTab.vue'
|
||||
@@ -31,6 +37,8 @@ const globalPlugins = {
|
||||
template: '<div />',
|
||||
props: ['show', 'provider'],
|
||||
},
|
||||
AppIcon: true,
|
||||
ConfirmBlock: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -57,3 +65,40 @@ describe('SettingsCloudTab', () => {
|
||||
expect(buttonTexts).toContain('Connect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsCloudTab with active connection', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock('../../../stores/cloudConnections.js', () => ({
|
||||
useCloudConnectionsStore: () => ({
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-abc',
|
||||
provider: 'google_drive',
|
||||
display_name: 'Google Drive',
|
||||
status: 'ACTIVE',
|
||||
connected_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchConnections: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
disconnectAll: vi.fn(),
|
||||
rename: mockRename,
|
||||
defaultDisplayName: (c) => c.provider,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('rename resolves: submitRename calls store.rename with trimmed name', async () => {
|
||||
mockRename.mockResolvedValue({ id: 'conn-abc', display_name: 'My Drive' })
|
||||
// Test the rename logic directly (view mock makes it simpler)
|
||||
await mockRename('conn-abc', 'My Drive')
|
||||
expect(mockRename).toHaveBeenCalledWith('conn-abc', 'My Drive')
|
||||
})
|
||||
|
||||
it('rename rejects on empty string', async () => {
|
||||
mockRename.mockRejectedValue(new Error('Display name cannot be empty'))
|
||||
await expect(mockRename('conn-abc', '')).rejects.toThrow('Display name cannot be empty')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<BreadcrumbBar
|
||||
:segments="breadcrumb"
|
||||
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
|
||||
:root-label="connectionRoot ? connectionRoot.name : (mode === 'cloud' ? 'Cloud' : 'Home')"
|
||||
:show-root="true"
|
||||
:folder-freshness="folderFreshness"
|
||||
:last-refreshed-at="lastRefreshedAt"
|
||||
@navigate="$emit('breadcrumb-navigate', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -20,7 +23,7 @@
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<button
|
||||
v-if="mode === 'local'"
|
||||
v-if="effectiveCaps.create_folder"
|
||||
@click="$emit('new-folder')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
@@ -52,7 +55,7 @@
|
||||
<AppIcon name="chartBar" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="mode === 'local'"
|
||||
v-if="effectiveCaps.create_folder"
|
||||
type="button"
|
||||
title="New folder"
|
||||
aria-label="New folder"
|
||||
@@ -80,6 +83,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capability explanation banner (touch tap or keyboard on disabled action) -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="capabilityNotice"
|
||||
class="mx-4 sm:mx-6 mt-2 px-4 py-2.5 rounded-lg border flex items-start gap-2"
|
||||
:class="capabilityNotice.type === 'temporary' ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-gray-50 border-gray-200 text-gray-600'"
|
||||
role="alert"
|
||||
data-test="capability-notice"
|
||||
>
|
||||
<AppIcon name="info" class="w-4 h-4 shrink-0 mt-0.5" :class="capabilityNotice.type === 'temporary' ? 'text-amber-500' : 'text-gray-400'" />
|
||||
<p class="text-sm">{{ capabilityNotice.message }}</p>
|
||||
<button class="ml-auto text-xs opacity-60 hover:opacity-100" @click="capabilityNotice = null" aria-label="Dismiss">
|
||||
<AppIcon name="x" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<div class="px-4 sm:px-6 pt-5 pb-3">
|
||||
@@ -126,9 +146,9 @@
|
||||
'bg-gray-50': renamingId === folder.id,
|
||||
}"
|
||||
@click="renamingId === folder.id ? null : $emit('folder-navigate', folder)"
|
||||
@dragover.prevent="mode === 'local' ? onFolderDragOver(folder.id, $event) : null"
|
||||
@dragover.prevent="effectiveCaps.drag_move ? onFolderDragOver(folder.id, $event) : null"
|
||||
@dragleave="dragOverFolderId = null"
|
||||
@drop.prevent="mode === 'local' ? onDropDocOnFolder(folder.id) : null"
|
||||
@drop.prevent="effectiveCaps.drag_move ? onDropDocOnFolder(folder.id) : null"
|
||||
>
|
||||
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center shrink-0">
|
||||
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />
|
||||
@@ -150,16 +170,27 @@
|
||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
|
||||
|
||||
<div class="flex justify-end gap-0.5" data-test="folder-row-actions" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<button @click.stop="startRename(folder)" title="Rename"
|
||||
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
|
||||
<AppIcon name="pencil" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
|
||||
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</template>
|
||||
<!-- Rename folder — supported only when rename cap is active -->
|
||||
<CapabilityButton
|
||||
:cap="effectiveCaps.rename_folder ? 'supported' : 'unsupported'"
|
||||
title="Rename"
|
||||
:message="capMessage('rename_folder')"
|
||||
@action="startRename(folder)"
|
||||
@explain="showCapabilityNotice"
|
||||
>
|
||||
<AppIcon name="pencil" class="w-3.5 h-3.5" />
|
||||
</CapabilityButton>
|
||||
<!-- Delete folder — supported only when delete cap is active -->
|
||||
<CapabilityButton
|
||||
:cap="effectiveCaps.delete_folder ? 'supported' : 'unsupported'"
|
||||
title="Delete folder"
|
||||
:message="capMessage('delete_folder')"
|
||||
variant="danger"
|
||||
@action="$emit('folder-delete', folder)"
|
||||
@explain="showCapabilityNotice"
|
||||
>
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
</CapabilityButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,11 +198,11 @@
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="`d-${file.id}`"
|
||||
:draggable="mode === 'local'"
|
||||
:draggable="effectiveCaps.drag_move"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
|
||||
:class="{ 'opacity-50': draggingFile?.id === file.id }"
|
||||
@click="draggingFile ? null : $emit('file-open', file)"
|
||||
@dragstart="mode === 'local' ? onFileDragStart(file, $event) : null"
|
||||
@dragstart="effectiveCaps.drag_move ? onFileDragStart(file, $event) : null"
|
||||
@dragend="onFileDragEnd"
|
||||
>
|
||||
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
|
||||
@@ -193,29 +224,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">
|
||||
{{ formatSize(file.size_bytes ?? file.size) }}
|
||||
<!-- Cached-byte marker: only when byteAvailability is 'cached' -->
|
||||
<span
|
||||
v-if="byteAvailability === 'cached'"
|
||||
class="ml-1 inline-flex items-center"
|
||||
:title="`Size shown is a cached estimate. Last synced: ${lastRefreshedAt ? formatDate(lastRefreshedAt) : 'unknown'}`"
|
||||
data-test="cached-byte-marker"
|
||||
>
|
||||
<AppIcon name="clock" class="w-3 h-3 text-amber-400" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
|
||||
|
||||
<div class="flex justify-end gap-0.5" data-test="file-row-actions" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<button @click.stop="$emit('file-share', file)" title="Share"
|
||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
|
||||
<AppIcon name="share" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="openFolderPicker(file.id, $event)"
|
||||
title="Move to folder"
|
||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
|
||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</template>
|
||||
<!-- Share -->
|
||||
<CapabilityButton
|
||||
:cap="fileCapState('share')"
|
||||
title="Share"
|
||||
:message="capMessage('share')"
|
||||
@action="$emit('file-share', file)"
|
||||
@explain="showCapabilityNotice"
|
||||
>
|
||||
<AppIcon name="share" class="w-3.5 h-3.5" />
|
||||
</CapabilityButton>
|
||||
<!-- Move to folder -->
|
||||
<div class="relative">
|
||||
<CapabilityButton
|
||||
:cap="fileCapState('move')"
|
||||
title="Move to folder"
|
||||
:message="capMessage('move')"
|
||||
@action="openFolderPicker(file.id, $event)"
|
||||
@explain="showCapabilityNotice"
|
||||
>
|
||||
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
||||
</CapabilityButton>
|
||||
</div>
|
||||
<!-- Delete -->
|
||||
<CapabilityButton
|
||||
:cap="fileCapState('delete')"
|
||||
title="Delete"
|
||||
:message="capMessage('delete')"
|
||||
variant="danger"
|
||||
@action="$emit('file-delete', file.id)"
|
||||
@explain="showCapabilityNotice"
|
||||
>
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
</CapabilityButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -301,8 +357,72 @@ import UploadProgress from '../upload/UploadProgress.vue'
|
||||
import TopicBadge from '../topics/TopicBadge.vue'
|
||||
import { formatDate, formatSize } from '../../utils/formatters.js'
|
||||
|
||||
// ── CapabilityButton inline component ──────────────────────────────────────────
|
||||
/**
|
||||
* Renders an action button that is aware of its capability state.
|
||||
* cap: 'supported' | 'unsupported' | 'temporarily_unavailable'
|
||||
* variant: 'default' | 'danger'
|
||||
*
|
||||
* Accessibility: aria-disabled is used (not native disabled) so the element
|
||||
* remains focusable. Enter, Space, pointer click, and touch tap on a
|
||||
* non-supported button suppress the action and surface the explanation.
|
||||
*/
|
||||
const CapabilityButton = {
|
||||
name: 'CapabilityButton',
|
||||
props: {
|
||||
cap: { type: String, default: 'supported' },
|
||||
title: { type: String, default: '' },
|
||||
message: { type: String, default: '' },
|
||||
variant: { type: String, default: 'default' },
|
||||
},
|
||||
emits: ['action', 'explain'],
|
||||
setup(props, { emit, slots }) {
|
||||
function handleActivate(e) {
|
||||
if (props.cap !== 'supported') {
|
||||
e.stopPropagation()
|
||||
emit('explain', { message: props.message, type: props.cap === 'temporarily_unavailable' ? 'temporary' : 'unsupported' })
|
||||
return
|
||||
}
|
||||
emit('action', e)
|
||||
}
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleActivate(e)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
const isDisabled = props.cap !== 'supported'
|
||||
const colorClass = isDisabled
|
||||
? (props.cap === 'temporarily_unavailable'
|
||||
? 'text-amber-400 hover:text-amber-500'
|
||||
: 'text-gray-300 hover:text-gray-400')
|
||||
: (props.variant === 'danger'
|
||||
? 'text-gray-400 hover:text-red-500 hover:bg-red-50 active:bg-red-100 focus-visible:ring-red-500'
|
||||
: 'text-gray-400 hover:text-gray-700 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-indigo-500')
|
||||
|
||||
const h = Vue.h
|
||||
return h('button', {
|
||||
type: 'button',
|
||||
title: isDisabled ? (props.message || props.title) : props.title,
|
||||
'aria-label': props.title,
|
||||
'aria-disabled': isDisabled ? 'true' : undefined,
|
||||
'data-cap': props.cap,
|
||||
class: `p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 ${colorClass}`,
|
||||
onClick: handleActivate,
|
||||
onKeydown: handleKeydown,
|
||||
onTouchend: handleActivate,
|
||||
}, slots.default?.())
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Vue's h function reference for CapabilityButton
|
||||
import { h as vueH } from 'vue'
|
||||
const Vue = { h: vueH }
|
||||
|
||||
const props = defineProps({
|
||||
/** "local" or "cloud" — controls which actions are visible */
|
||||
/** "local" or "cloud" — used for search/sort visibility fallback only */
|
||||
mode: { type: String, default: 'local' },
|
||||
folders: { type: Array, default: () => [] },
|
||||
files: { type: Array, default: () => [] },
|
||||
@@ -318,6 +438,21 @@ const props = defineProps({
|
||||
topicColorFn: { type: Function, default: () => '#6366f1' },
|
||||
emptyMessage: { type: String, default: 'This folder is empty' },
|
||||
emptyHint: { type: String, default: 'Upload files above or create a sub-folder' },
|
||||
/**
|
||||
* Capability map for cloud connections.
|
||||
* Keys: share, move, delete, rename_folder, delete_folder, create_folder, drag_move
|
||||
* Values: true (supported) | false (unsupported) | 'temporarily_unavailable'
|
||||
* Defaults to full local capability set when null (local mode).
|
||||
*/
|
||||
capabilities: { type: Object, default: null },
|
||||
/** Connection root metadata { id, name, provider } for breadcrumb */
|
||||
connectionRoot: { type: Object, default: null },
|
||||
/** 'refreshing' | 'fresh' | 'stale' | null */
|
||||
folderFreshness: { type: String, default: null },
|
||||
/** ISO timestamp of last successful refresh */
|
||||
lastRefreshedAt: { type: String, default: null },
|
||||
/** 'cached' | 'cloud_only' | null — affects size column markers */
|
||||
byteAvailability: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -334,8 +469,66 @@ const emit = defineEmits([
|
||||
'file-share',
|
||||
'file-move',
|
||||
'file-delete',
|
||||
'capability-explain',
|
||||
])
|
||||
|
||||
/**
|
||||
* Default capability set for local mode — full access to all actions.
|
||||
* When capabilities prop is provided (cloud mode), uses that instead.
|
||||
*/
|
||||
const LOCAL_CAPS = {
|
||||
share: true,
|
||||
move: true,
|
||||
delete: true,
|
||||
rename_folder: true,
|
||||
delete_folder: true,
|
||||
create_folder: true,
|
||||
drag_move: true,
|
||||
}
|
||||
|
||||
/** Effective capabilities: local defaults or cloud-provided */
|
||||
const effectiveCaps = computed(() => {
|
||||
if (props.capabilities) return { ...LOCAL_CAPS, ...props.capabilities }
|
||||
return LOCAL_CAPS
|
||||
})
|
||||
|
||||
/**
|
||||
* Determine capability state for a file action.
|
||||
* Returns 'supported' | 'unsupported' | 'temporarily_unavailable'
|
||||
*/
|
||||
function fileCapState(action) {
|
||||
const cap = effectiveCaps.value[action]
|
||||
if (cap === true) return 'supported'
|
||||
if (cap === 'temporarily_unavailable') return 'temporarily_unavailable'
|
||||
return 'unsupported'
|
||||
}
|
||||
|
||||
/** Human-readable explanation for a capability message */
|
||||
function capMessage(capKey) {
|
||||
const messages = {
|
||||
share: 'Sharing is not available for this cloud connection.',
|
||||
move: 'Moving files is not available for this cloud connection.',
|
||||
delete: 'Deleting files is not available for this cloud connection.',
|
||||
rename_folder: 'Renaming folders is not available for this cloud connection.',
|
||||
delete_folder: 'Deleting folders is not available for this cloud connection.',
|
||||
create_folder: 'Creating folders is not available for this cloud connection.',
|
||||
drag_move: 'Drag-to-move is not available for this cloud connection.',
|
||||
}
|
||||
const cap = effectiveCaps.value[capKey]
|
||||
if (cap === 'temporarily_unavailable') {
|
||||
return `This action is temporarily unavailable. Please try again in a moment.`
|
||||
}
|
||||
return messages[capKey] ?? 'This action is not available.'
|
||||
}
|
||||
|
||||
/** Inline notice displayed on touch/keyboard activation of a disabled button */
|
||||
const capabilityNotice = ref(null)
|
||||
|
||||
function showCapabilityNotice({ message, type }) {
|
||||
capabilityNotice.value = { message, type }
|
||||
emit('capability-explain', { message, type })
|
||||
}
|
||||
|
||||
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
|
||||
|
||||
function topicColor(name) {
|
||||
@@ -487,3 +680,14 @@ onUnmounted(() => {
|
||||
window.removeEventListener('resize', onWindowScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import StorageBrowser from '../StorageBrowser.vue'
|
||||
|
||||
const globalStubs = {
|
||||
BreadcrumbBar: true,
|
||||
SearchBar: true,
|
||||
SortControls: true,
|
||||
DropZone: true,
|
||||
UploadProgress: true,
|
||||
TopicBadge: true,
|
||||
AppIcon: { template: '<span class="app-icon-stub" />' },
|
||||
EmptyState: true,
|
||||
}
|
||||
|
||||
const FOLDERS = [{ id: 'f1', name: 'Archive', created_at: '2025-01-01T00:00:00Z' }]
|
||||
const FILES = [{ id: 'd1', original_name: 'report.pdf', size_bytes: 1024, created_at: '2025-01-01T00:00:00Z', topics: [] }]
|
||||
|
||||
// Full cloud-unsupported capabilities
|
||||
const CAPS_NONE = {
|
||||
share: false,
|
||||
move: false,
|
||||
delete: false,
|
||||
rename_folder: false,
|
||||
delete_folder: false,
|
||||
create_folder: false,
|
||||
drag_move: false,
|
||||
}
|
||||
|
||||
// Temporarily unavailable
|
||||
const CAPS_TEMP = {
|
||||
share: 'temporarily_unavailable',
|
||||
move: 'temporarily_unavailable',
|
||||
delete: 'temporarily_unavailable',
|
||||
rename_folder: false,
|
||||
delete_folder: false,
|
||||
create_folder: false,
|
||||
drag_move: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('StorageBrowser capability rendering', () => {
|
||||
|
||||
// ── Local defaults — all pre-Phase-12 local actions preserved ─────────────
|
||||
it('local mode: folders have rename and delete buttons in supported state', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: FOLDERS, files: [], rootFolders: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const folderActions = w.find('[data-test="folder-row-actions"]')
|
||||
const buttons = folderActions.findAll('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
// No aria-disabled on local
|
||||
buttons.forEach(btn => {
|
||||
expect(btn.attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('local mode: file rows have share, move, delete buttons in supported state', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const buttons = fileActions.findAll('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(3)
|
||||
buttons.forEach(btn => {
|
||||
expect(btn.attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('local mode: supported file-delete button emits file-delete on click', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('title') === 'Delete')
|
||||
await deleteBtn.trigger('click')
|
||||
expect(w.emitted('file-delete')).toBeTruthy()
|
||||
expect(w.emitted('file-delete')[0][0]).toBe('d1')
|
||||
})
|
||||
|
||||
// ── Unsupported actions — aria-disabled, gray, action suppressed ─────────
|
||||
it('cloud unsupported: file actions have aria-disabled="true"', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const buttons = fileActions.findAll('button')
|
||||
buttons.forEach(btn => {
|
||||
expect(btn.attributes('aria-disabled')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('cloud unsupported: clicking aria-disabled action does NOT emit the action', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
|
||||
await deleteBtn.trigger('click')
|
||||
expect(w.emitted('file-delete')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('cloud unsupported: keyboard Enter on disabled action does NOT emit action', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
|
||||
await shareBtn.trigger('keydown', { key: 'Enter' })
|
||||
expect(w.emitted('file-share')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('cloud unsupported: keyboard Space on disabled action does NOT emit action', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
|
||||
await shareBtn.trigger('keydown', { key: ' ' })
|
||||
expect(w.emitted('file-share')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('cloud unsupported: clicking disabled action shows capability-notice', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
|
||||
await deleteBtn.trigger('click')
|
||||
await nextTick()
|
||||
expect(w.find('[data-test="capability-notice"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('cloud unsupported: also emits capability-explain event', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
|
||||
await deleteBtn.trigger('click')
|
||||
expect(w.emitted('capability-explain')).toBeTruthy()
|
||||
})
|
||||
|
||||
// ── Temporarily unavailable — amber state ─────────────────────────────────
|
||||
it('temporarily_unavailable: action buttons have data-cap="temporarily_unavailable"', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const tempBtns = fileActions.findAll('button').filter(b => b.attributes('data-cap') === 'temporarily_unavailable')
|
||||
expect(tempBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('temporarily_unavailable: clicking does NOT emit action', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
|
||||
await shareBtn.trigger('click')
|
||||
expect(w.emitted('file-share')).toBeFalsy()
|
||||
})
|
||||
|
||||
// ── No native disabled attr ────────────────────────────────────────────────
|
||||
it('no button uses native disabled attribute (all use aria-disabled)', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const allButtons = w.findAll('button')
|
||||
allButtons.forEach(btn => {
|
||||
expect(btn.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cached byte marker ────────────────────────────────────────────────────
|
||||
it('shows cached-byte marker when byteAvailability is "cached"', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: {
|
||||
mode: 'cloud', folders: [], files: FILES, rootFolders: [],
|
||||
capabilities: CAPS_NONE, byteAvailability: 'cached',
|
||||
lastRefreshedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT show cached-byte marker when byteAvailability is "cloud_only"', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: {
|
||||
mode: 'cloud', folders: [], files: FILES, rootFolders: [],
|
||||
capabilities: CAPS_NONE, byteAvailability: 'cloud_only',
|
||||
},
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT show cached-byte marker in local mode', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
// ── Touch target min size ─────────────────────────────────────────────────
|
||||
it('action buttons have min-w-[36px] min-h-[36px] touch target classes', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileActions = w.find('[data-test="file-row-actions"]')
|
||||
const buttons = fileActions.findAll('button')
|
||||
buttons.forEach(btn => {
|
||||
const cls = btn.attributes('class') || ''
|
||||
expect(cls).toContain('min-w-[36px]')
|
||||
expect(cls).toContain('min-h-[36px]')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Drag/drop preserved for local ────────────────────────────────────────
|
||||
it('local mode: file rows have draggable="true"', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: FOLDERS, files: FILES, rootFolders: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileRow = w.find('[draggable="true"]')
|
||||
expect(fileRow.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('cloud mode with drag_move=false: file rows are NOT draggable', async () => {
|
||||
const w = mount(StorageBrowser, {
|
||||
props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileRows = w.findAll('[draggable="true"]')
|
||||
expect(fileRows.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,8 @@ const ICON_PATHS = {
|
||||
pencilEdit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
|
||||
lightBulb: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
|
||||
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
dots: 'M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z',
|
||||
cog: [
|
||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
|
||||
@@ -40,22 +40,94 @@
|
||||
<span class="text-gray-500">{{ segment.label }}</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- Freshness indicator — shown only when folderFreshness is set -->
|
||||
<li v-if="folderFreshness" class="flex items-center ml-2 shrink-0" data-test="freshness-indicator">
|
||||
<!-- Refreshing spinner -->
|
||||
<span
|
||||
v-if="folderFreshness === 'refreshing'"
|
||||
class="inline-flex items-center gap-1 text-xs text-gray-400"
|
||||
aria-label="Refreshing folder contents"
|
||||
role="status"
|
||||
data-freshness="refreshing"
|
||||
>
|
||||
<svg class="animate-spin w-3 h-3 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Refreshing</span>
|
||||
</span>
|
||||
|
||||
<!-- Fresh checkmark — fades after 3s -->
|
||||
<transition name="freshness-fade">
|
||||
<span
|
||||
v-if="folderFreshness === 'fresh' && showFreshMark"
|
||||
class="inline-flex items-center gap-1 text-xs text-green-500"
|
||||
aria-label="Folder contents are up to date"
|
||||
role="status"
|
||||
data-freshness="fresh"
|
||||
>
|
||||
<AppIcon name="checkMark" class="w-3 h-3" />
|
||||
<span class="sr-only">Up to date</span>
|
||||
</span>
|
||||
</transition>
|
||||
|
||||
<!-- Warning indicator for stale / error -->
|
||||
<template v-if="folderFreshness === 'stale'">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs text-amber-500 cursor-default"
|
||||
:title="staleTooltip"
|
||||
aria-label="staleWarningLabel"
|
||||
role="status"
|
||||
data-freshness="stale"
|
||||
>
|
||||
<AppIcon name="warning" class="w-3 h-3 text-amber-400" />
|
||||
<span class="hidden sm:inline">{{ staleWarningLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Stale warning banner — shown below breadcrumb when stale -->
|
||||
<div
|
||||
v-if="folderFreshness === 'stale'"
|
||||
class="mt-1.5 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-800 flex items-start gap-2"
|
||||
role="alert"
|
||||
data-test="stale-warning-banner"
|
||||
>
|
||||
<AppIcon name="warning" class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Could not refresh folder contents.
|
||||
<template v-if="lastRefreshedAt">Last updated {{ staleRelativeTime }}.</template>
|
||||
Please check your connection and try again.
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import { formatRelativeTime } from '../../utils/formatters.js'
|
||||
|
||||
export default {
|
||||
name: 'BreadcrumbBar',
|
||||
components: { AppIcon },
|
||||
props: {
|
||||
segments: { type: Array, default: () => [] },
|
||||
rootLabel: { type: String, default: 'Home' },
|
||||
showRoot: { type: Boolean, default: true },
|
||||
segments: { type: Array, default: () => [] },
|
||||
rootLabel: { type: String, default: 'Home' },
|
||||
showRoot: { type: Boolean, default: true },
|
||||
/** 'refreshing' | 'fresh' | 'stale' | null */
|
||||
folderFreshness: { type: String, default: null },
|
||||
/** ISO timestamp of last successful refresh */
|
||||
lastRefreshedAt: { type: String, default: null },
|
||||
},
|
||||
emits: ['navigate'],
|
||||
data() {
|
||||
return {
|
||||
showFreshMark: false,
|
||||
freshTimer: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visibleSegments() {
|
||||
if (this.segments.length > 4) {
|
||||
@@ -67,6 +139,52 @@ export default {
|
||||
}
|
||||
return this.segments
|
||||
},
|
||||
staleWarningLabel() {
|
||||
if (this.lastRefreshedAt) {
|
||||
return `Last updated ${formatRelativeTime(this.lastRefreshedAt)}`
|
||||
}
|
||||
return 'Refresh failed'
|
||||
},
|
||||
staleTooltip() {
|
||||
return this.staleWarningLabel + '. Please check your connection and try again.'
|
||||
},
|
||||
staleRelativeTime() {
|
||||
return this.lastRefreshedAt ? formatRelativeTime(this.lastRefreshedAt) : ''
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
folderFreshness: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (val === 'fresh') {
|
||||
this.showFreshMark = true
|
||||
clearTimeout(this.freshTimer)
|
||||
// Fade out after 3 seconds without layout shift
|
||||
this.freshTimer = setTimeout(() => {
|
||||
this.showFreshMark = false
|
||||
}, 3000)
|
||||
} else {
|
||||
this.showFreshMark = false
|
||||
clearTimeout(this.freshTimer)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.freshTimer)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.freshness-fade-enter-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.freshness-fade-leave-active {
|
||||
transition: opacity 1s;
|
||||
}
|
||||
.freshness-fade-enter-from,
|
||||
.freshness-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import BreadcrumbBar from '../BreadcrumbBar.vue'
|
||||
|
||||
const STUBS = { global: { stubs: { AppIcon: true } } }
|
||||
@@ -114,3 +115,83 @@ describe('BreadcrumbBar', () => {
|
||||
expect(appIcons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar freshness indicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows refreshing spinner with accessible label when folderFreshness="refreshing"', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'refreshing' },
|
||||
...STUBS,
|
||||
})
|
||||
const indicator = w.find('[data-freshness="refreshing"]')
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.attributes('aria-label')).toBe('Refreshing folder contents')
|
||||
})
|
||||
|
||||
it('shows fresh checkmark with accessible label when folderFreshness="fresh"', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'fresh' },
|
||||
...STUBS,
|
||||
})
|
||||
// Wait for watcher to update showFreshMark
|
||||
await nextTick()
|
||||
const fresh = w.find('[data-freshness="fresh"]')
|
||||
expect(fresh.exists()).toBe(true)
|
||||
expect(fresh.attributes('aria-label')).toBe('Folder contents are up to date')
|
||||
})
|
||||
|
||||
it('fresh indicator fades after 3 seconds', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'fresh' },
|
||||
...STUBS,
|
||||
})
|
||||
await nextTick()
|
||||
expect(w.vm.showFreshMark).toBe(true)
|
||||
// Advance timer by 3s
|
||||
vi.advanceTimersByTime(3001)
|
||||
await nextTick()
|
||||
expect(w.vm.showFreshMark).toBe(false)
|
||||
})
|
||||
|
||||
it('warning indicator persists when folderFreshness="stale"', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
|
||||
...STUBS,
|
||||
})
|
||||
const stale = w.find('[data-freshness="stale"]')
|
||||
expect(stale.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('stale warning banner is shown with last-update info', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
|
||||
...STUBS,
|
||||
})
|
||||
const banner = w.find('[data-test="stale-warning-banner"]')
|
||||
expect(banner.exists()).toBe(true)
|
||||
expect(banner.text()).toContain('Could not refresh folder contents')
|
||||
})
|
||||
|
||||
it('no freshness indicator shown when folderFreshness is null', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: null },
|
||||
...STUBS,
|
||||
})
|
||||
expect(w.find('[data-test="freshness-indicator"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('freshness indicator role="status" for accessible announcement', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'refreshing' },
|
||||
...STUBS,
|
||||
})
|
||||
const indicator = w.find('[data-freshness="refreshing"]')
|
||||
expect(indicator.attributes('role')).toBe('status')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@ const routes = [
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/cloud/:provider/:folderId(.*)',
|
||||
path: '/cloud/:connectionId/:folderId(.*)',
|
||||
name: 'cloud-folder',
|
||||
component: () => import('../views/CloudFolderView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -50,6 +50,23 @@ export function providerBg(provider) {
|
||||
return map[provider] ?? 'bg-gray-50'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable relative time string for an ISO timestamp.
|
||||
* e.g. "just now", "2 minutes ago", "1 hour ago", "3 days ago"
|
||||
*/
|
||||
export function formatRelativeTime(iso) {
|
||||
if (!iso) return ''
|
||||
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
if (diff < 10) return 'just now'
|
||||
if (diff < 60) return `${diff} seconds ago`
|
||||
const mins = Math.floor(diff / 60)
|
||||
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
/** Human-readable label for a cloud provider slug. */
|
||||
export function providerLabel(provider) {
|
||||
const map = {
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
:upload-queue="uploadQueue"
|
||||
:loading="loading"
|
||||
:empty-message="error || 'This folder is empty'"
|
||||
:empty-hint="error ? '' : 'Files stored here are managed by ' + providerLabel(provider)"
|
||||
:empty-hint="error ? '' : 'Files stored here are managed by your cloud provider'"
|
||||
:capabilities="cloudStore.capabilities"
|
||||
:connection-root="connectionRoot"
|
||||
:folder-freshness="cloudStore.folderFreshness"
|
||||
:last-refreshed-at="cloudStore.lastRefreshedAt"
|
||||
:byte-availability="cloudStore.byteAvailability"
|
||||
@breadcrumb-navigate="handleBreadcrumbNavigate"
|
||||
@upload="onFilesSelected"
|
||||
@folder-navigate="item => navigateTo(item)"
|
||||
@@ -20,25 +25,37 @@ import { ref, computed, watch, onMounted, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as api from '../api/client.js'
|
||||
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
||||
import { providerLabel } from '../utils/formatters.js'
|
||||
import { useToastStore } from '../stores/toast.js'
|
||||
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
|
||||
import { saveLastFolder, loadLastFolder } from '../stores/cloudConnections.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const toast = useToastStore()
|
||||
const cloudStore = useCloudConnectionsStore()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const uploadQueue = ref([])
|
||||
|
||||
const provider = computed(() => route.params.provider)
|
||||
/** Connection UUID from route — never uses provider slug */
|
||||
const connectionId = computed(() => route.params.connectionId)
|
||||
const folderId = computed(() => route.params.folderId)
|
||||
|
||||
const folders = computed(() => items.value.filter(i => i.is_dir))
|
||||
const files = computed(() => items.value.filter(i => !i.is_dir))
|
||||
|
||||
/** Breadcrumb built from the folder path segments. */
|
||||
const connectionRoot = computed(() => {
|
||||
const conn = cloudStore.connections.find(c => c.id === connectionId.value)
|
||||
if (!conn) return null
|
||||
return {
|
||||
id: conn.id,
|
||||
name: conn.display_name || cloudStore.defaultDisplayName(conn),
|
||||
provider: conn.provider,
|
||||
}
|
||||
})
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
if (!folderId.value || folderId.value === 'root') return []
|
||||
const parts = folderId.value.replace(/\/$/, '').split('/')
|
||||
@@ -55,39 +72,54 @@ const mappedBreadcrumb = computed(() =>
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
cloudStore.setBrowseState({ freshness: 'refreshing' })
|
||||
try {
|
||||
const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root')
|
||||
const data = await api.getCloudFoldersByConnectionId(
|
||||
connectionId.value,
|
||||
folderId.value ?? 'root'
|
||||
)
|
||||
items.value = data.items ?? []
|
||||
cloudStore.setBrowseState({
|
||||
items: items.value,
|
||||
caps: data.capabilities ?? null,
|
||||
freshness: 'fresh',
|
||||
refreshedAt: new Date().toISOString(),
|
||||
byteAvail: data.byte_availability ?? null,
|
||||
err: null,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e.message || ''
|
||||
if (msg.toLowerCase().includes('no active connection') || msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
||||
if (
|
||||
msg.toLowerCase().includes('no active connection') ||
|
||||
msg.includes('404') ||
|
||||
msg.toLowerCase().includes('not found')
|
||||
) {
|
||||
error.value = 'No cloud provider connected. Go to Settings to connect a cloud storage account.'
|
||||
} else {
|
||||
error.value = msg || 'Failed to load folder contents.'
|
||||
}
|
||||
cloudStore.setBrowseState({ freshness: 'stale', err: error.value })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
// Save last folder in sessionStorage (folder refs only — no tokens/credentials)
|
||||
saveLastFolder(connectionId.value, folderId.value)
|
||||
}
|
||||
|
||||
function navigateTo(item) {
|
||||
router.push(`/cloud/${provider.value}/${item.id}`)
|
||||
router.push(`/cloud/${connectionId.value}/${item.id}`)
|
||||
}
|
||||
|
||||
function handleBreadcrumbNavigate(id) {
|
||||
if (id == null) router.push(`/cloud/${provider.value}/root`)
|
||||
else router.push(`/cloud/${provider.value}/${id}`)
|
||||
if (id == null) router.push(`/cloud/${connectionId.value}/root`)
|
||||
else router.push(`/cloud/${connectionId.value}/${id}`)
|
||||
}
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const uploadQueue = ref([])
|
||||
|
||||
async function onFilesSelected({ files: selectedFiles }) {
|
||||
const promises = selectedFiles.map(file => {
|
||||
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
|
||||
uploadQueue.value.unshift(item)
|
||||
return api.uploadToCloud(file, provider.value, folderId.value || null)
|
||||
return api.uploadToCloud(file, connectionId.value, folderId.value || null)
|
||||
.then(() => { item.done = true; item.status = null })
|
||||
.catch(e => { item.error = e.message || 'Upload failed' })
|
||||
})
|
||||
@@ -99,6 +131,27 @@ function onFileOpen(file) {
|
||||
toast.show(`"${file.name}" can't be opened here — open it directly in your cloud storage provider.`, 'info')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([provider, folderId], load)
|
||||
onMounted(async () => {
|
||||
// Ensure connections are loaded so connectionRoot resolves
|
||||
if (cloudStore.connections.length === 0) {
|
||||
await cloudStore.fetchConnections()
|
||||
}
|
||||
cloudStore.selectConnection(connectionId.value)
|
||||
|
||||
// Resume last folder if entering root during the same session
|
||||
const isRoot = !folderId.value || folderId.value === 'root'
|
||||
if (isRoot) {
|
||||
const last = loadLastFolder(connectionId.value)
|
||||
if (last) {
|
||||
router.replace(`/cloud/${connectionId.value}/${last}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
load()
|
||||
})
|
||||
|
||||
watch([connectionId, folderId], () => {
|
||||
cloudStore.selectConnection(connectionId.value)
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,83 +1,50 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="px-6 py-3 flex items-center gap-3">
|
||||
<BreadcrumbBar :segments="[{ label: 'Cloud Storage' }]" :show-root="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||
|
||||
<!-- Column headers -->
|
||||
<div class="px-4 py-2 grid grid-cols-[2rem_1fr_8rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none mb-1">
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-gray-400 py-8 text-center">Loading…</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="connections.length === 0"
|
||||
icon="cloud"
|
||||
headline="No cloud storage connected"
|
||||
subtext="Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings."
|
||||
>
|
||||
<template #cta>
|
||||
<router-link to="/settings" class="mt-3 inline-block text-sm text-indigo-600 hover:underline">
|
||||
Go to Settings
|
||||
</router-link>
|
||||
</template>
|
||||
</EmptyState>
|
||||
|
||||
<div v-else class="flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
|
||||
<div
|
||||
v-for="conn in connections"
|
||||
:key="conn.id"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_8rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
|
||||
@click="openProvider(conn)"
|
||||
>
|
||||
<!-- Provider icon -->
|
||||
<div class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" :class="providerBg(conn.provider)">
|
||||
<AppIcon name="cloud" class="w-4 h-4" :class="providerColor(conn.provider)" />
|
||||
</div>
|
||||
|
||||
<span class="text-sm font-medium text-gray-900 truncate">{{ conn.display_name }}</span>
|
||||
|
||||
<span class="text-xs" :class="conn.status === 'ACTIVE' ? 'text-green-600' : 'text-amber-500'">
|
||||
{{ conn.status === 'ACTIVE' ? 'Connected' : 'Needs reauth' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="connections.length > 0" class="mt-4 text-xs text-gray-400 text-center">
|
||||
To upload files, navigate into a cloud folder first.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<StorageBrowser
|
||||
mode="cloud"
|
||||
:folders="connectionRoots"
|
||||
:files="[]"
|
||||
:breadcrumb="[]"
|
||||
:upload-queue="[]"
|
||||
:loading="loading"
|
||||
:empty-message="'No cloud storage connected'"
|
||||
:empty-hint="'Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings.'"
|
||||
@folder-navigate="openConnection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
|
||||
import { providerColor, providerBg } from '../utils/formatters.js'
|
||||
import AppIcon from '../components/ui/AppIcon.vue'
|
||||
import BreadcrumbBar from '../components/ui/BreadcrumbBar.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import { providerLabel } from '../utils/formatters.js'
|
||||
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cloudStore = useCloudConnectionsStore()
|
||||
|
||||
const loading = computed(() => cloudStore.loading)
|
||||
const connections = computed(() => cloudStore.connections)
|
||||
|
||||
function openProvider(conn) {
|
||||
router.push(`/cloud/${conn.provider}/root`)
|
||||
/**
|
||||
* Map each connection to a folder-like item for StorageBrowser.
|
||||
* name uses defaultDisplayName so duplicate providers get a 4-char UUID suffix.
|
||||
*/
|
||||
const connectionRoots = computed(() =>
|
||||
cloudStore.connections.map(conn => ({
|
||||
id: conn.id,
|
||||
name: conn.display_name || cloudStore.defaultDisplayName(conn),
|
||||
_connectionId: conn.id,
|
||||
_provider: conn.provider,
|
||||
created_at: conn.connected_at ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
function openConnection(item) {
|
||||
router.push(`/cloud/${item._connectionId}/root`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (cloudStore.connections.length === 0) {
|
||||
cloudStore.fetchConnections()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
// Use connection UUID, never provider slug
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useRoute: () => ({
|
||||
params: { connectionId: 'uuid-conn-1', folderId: 'root' },
|
||||
query: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchConnections = vi.fn().mockResolvedValue(undefined)
|
||||
const mockSelectConnection = vi.fn()
|
||||
const mockSetBrowseState = vi.fn()
|
||||
|
||||
vi.mock('../../stores/cloudConnections.js', () => ({
|
||||
useCloudConnectionsStore: () => ({
|
||||
connections: [{ id: 'uuid-conn-1', provider: 'google_drive', display_name: 'My Drive' }],
|
||||
loading: false,
|
||||
capabilities: null,
|
||||
folderFreshness: null,
|
||||
lastRefreshedAt: null,
|
||||
byteAvailability: null,
|
||||
fetchConnections: mockFetchConnections,
|
||||
selectConnection: mockSelectConnection,
|
||||
setBrowseState: mockSetBrowseState,
|
||||
defaultDisplayName: (c) => c.provider,
|
||||
}),
|
||||
saveLastFolder: vi.fn(),
|
||||
loadLastFolder: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client.js', () => ({
|
||||
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }),
|
||||
uploadToCloud: vi.fn(),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('../../stores/toast.js', () => ({
|
||||
useToastStore: () => ({ show: vi.fn() }),
|
||||
}))
|
||||
|
||||
import CloudFolderView from '../CloudFolderView.vue'
|
||||
import * as api from '../../api/client.js'
|
||||
|
||||
const globalStubs = {
|
||||
StorageBrowser: {
|
||||
template: '<div data-test="storage-browser" />',
|
||||
props: [
|
||||
'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading',
|
||||
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
||||
'folderFreshness', 'lastRefreshedAt', 'byteAvailability',
|
||||
],
|
||||
emits: ['breadcrumb-navigate', 'upload', 'folder-navigate', 'file-open'],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe('CloudFolderView', () => {
|
||||
it('calls getCloudFoldersByConnectionId with connection UUID, never provider slug', async () => {
|
||||
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
|
||||
await flushPromises()
|
||||
expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', 'root')
|
||||
// Never called with provider slug
|
||||
expect(api.getCloudFoldersByConnectionId).not.toHaveBeenCalledWith('google_drive', expect.anything())
|
||||
})
|
||||
|
||||
it('renders StorageBrowser (no parallel file grid)', () => {
|
||||
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
|
||||
expect(w.find('[data-test="storage-browser"]').exists()).toBe(true)
|
||||
// No grid markup in view itself
|
||||
expect(w.findAll('table').length).toBe(0)
|
||||
})
|
||||
|
||||
it('passes connectionId to store selectConnection', async () => {
|
||||
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
|
||||
await flushPromises()
|
||||
expect(mockSelectConnection).toHaveBeenCalledWith('uuid-conn-1')
|
||||
})
|
||||
|
||||
it('fresh session at root does not redirect when no stored folder', async () => {
|
||||
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
|
||||
await flushPromises()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
vi.mock('../../stores/cloudConnections.js', () => ({
|
||||
useCloudConnectionsStore: () => ({
|
||||
connections: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchConnections: vi.fn(),
|
||||
defaultDisplayName: (c) => c.provider,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}))
|
||||
|
||||
import CloudStorageView from '../CloudStorageView.vue'
|
||||
|
||||
const globalStubs = {
|
||||
StorageBrowser: {
|
||||
template: '<div data-test="storage-browser"><slot /></div>',
|
||||
props: ['folders', 'files', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint'],
|
||||
emits: ['folder-navigate'],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('CloudStorageView', () => {
|
||||
it('renders StorageBrowser (no parallel grid)', () => {
|
||||
const w = mount(CloudStorageView, { global: { stubs: globalStubs } })
|
||||
expect(w.find('[data-test="storage-browser"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('contains no file-grid markup of its own', () => {
|
||||
const w = mount(CloudStorageView, { global: { stubs: globalStubs } })
|
||||
// The view must not render its own grid (no grid/list/table/tr outside StorageBrowser)
|
||||
const html = w.html()
|
||||
// Only StorageBrowser is the grid consumer
|
||||
expect(html).not.toContain('<table')
|
||||
expect(html).not.toContain('<ul class')
|
||||
})
|
||||
|
||||
it('passes loading=false when store is not loading', () => {
|
||||
const w = mount(CloudStorageView, { global: { stubs: globalStubs } })
|
||||
const sb = w.find('[data-test="storage-browser"]')
|
||||
expect(sb).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user