feat(phase-4): complete UX redesign — FileManagerView, FolderTreeItem, test suite, and all Phase 4 fixes

Adds the unified file manager view (Windows Explorer-style), collapsible
folder tree sidebar item, full vitest test suite (55 tests, 4 files), and
commits all Phase 4 backend/frontend fixes that were staged but uncommitted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-28 17:10:52 +02:00
parent 654622d358
commit 87a32b7ee8
25 changed files with 2534 additions and 163 deletions
+8 -5
View File
@@ -8,15 +8,18 @@
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0"
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.2.0",
"tailwindcss": "^3.4.0",
"@vue/test-utils": "^2.4.10",
"autoprefixer": "^10.4.0",
"happy-dom": "^20.9.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
"tailwindcss": "^3.4.0",
"vite": "^5.2.0",
"vitest": "^4.1.7"
}
}
@@ -106,7 +106,7 @@ const foldersStore = useFoldersStore()
const showShareModal = ref(false)
const showFolderPicker = ref(false)
const allFolders = computed(() => foldersStore.folders)
const allFolders = computed(() => foldersStore.rootFolders)
function openShareModal() {
showShareModal.value = true
@@ -0,0 +1,108 @@
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow hidden when folder is a known leaf (has_children === false) -->
<button
v-if="folder.has_children !== false && (!childrenLoaded || (children && children.length > 0))"
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse' : 'Expand'"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Spacer for leaf nodes -->
<span v-else class="w-5 h-5 shrink-0"></span>
<!-- Folder name (router-link) -->
<router-link
:to="`/folders/${folder.id}`"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
>
<svg
class="w-4 h-4 shrink-0"
:class="isActive ? 'text-indigo-500' : 'text-gray-400'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<span class="truncate">{{ folder.name }}</span>
</router-link>
</div>
<!-- Children (recursively rendered) -->
<div v-if="expanded && children && children.length > 0">
<FolderTreeItem
v-for="child in children"
:key="child.id"
:folder="child"
:depth="depth + 1"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useFoldersStore } from '../../stores/folders.js'
import * as api from '../../api/client.js'
const props = defineProps({
folder: { type: Object, required: true },
depth: { type: Number, default: 0 },
})
const route = useRoute()
const foldersStore = useFoldersStore()
const expanded = ref(false)
const children = ref(null) // null = not yet loaded
const childrenLoaded = ref(false)
const isActive = computed(() =>
route.params.folderId != null &&
(route.params.folderId === String(props.folder.id) || route.params.folderId === props.folder.id)
)
async function loadChildren() {
try {
const data = await api.listFolders(props.folder.id)
children.value = data.items ?? data
} catch {
children.value = []
}
childrenLoaded.value = true
}
async function toggleExpand() {
if (!childrenLoaded.value) {
await loadChildren()
}
expanded.value = !expanded.value
}
// Re-fetch children when any folder mutation occurs (create/rename/delete)
watch(() => foldersStore.treeVersion, () => {
if (expanded.value && childrenLoaded.value) {
loadChildren()
}
})
</script>
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FolderBreadcrumb from '../FolderBreadcrumb.vue'
function seg(id, name) { return { id, name } }
describe('FolderBreadcrumb', () => {
it('always renders a "Home" / "Folders" root button', () => {
const w = mount(FolderBreadcrumb, { props: { segments: [] } })
expect(w.find('button').exists()).toBe(true)
})
it('clicking root button emits navigate(null)', async () => {
const w = mount(FolderBreadcrumb, { props: { segments: [] } })
await w.find('button').trigger('click')
expect(w.emitted('navigate')).toBeTruthy()
expect(w.emitted('navigate')[0]).toEqual([null])
})
it('renders intermediate segments as clickable buttons', () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
// "Root" is intermediate (not last), "Test" is last (plain text)
const buttons = w.findAll('button')
// first button is "Home/Folders", second is "Root"
expect(buttons.length).toBe(2)
expect(buttons[1].text()).toBe('Root')
})
it('clicking intermediate segment emits navigate(id)', async () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
const buttons = w.findAll('button')
await buttons[1].trigger('click') // "Root" button
expect(w.emitted('navigate')).toBeTruthy()
expect(w.emitted('navigate')[0]).toEqual(['r1'])
})
it('renders last segment as plain non-interactive text', () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
// Last segment "Test" should be a <span>, not a button
const spans = w.findAll('span')
const lastSpan = spans.find(s => s.text() === 'Test')
expect(lastSpan).toBeTruthy()
})
it('last segment is NOT clickable (no navigate event)', async () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
})
const spans = w.findAll('span')
const lastSpan = spans.find(s => s.text() === 'Test')
if (lastSpan) await lastSpan.trigger('click')
// navigate should NOT have been emitted by clicking the last segment
const navigateEvents = (w.emitted('navigate') || []).filter(e => e[0] === 'f1')
expect(navigateEvents.length).toBe(0)
})
it('single segment: just root button + last segment as text', () => {
const w = mount(FolderBreadcrumb, {
props: { segments: [seg('f1', 'OnlyFolder')] },
})
// Only the "Home" button and "OnlyFolder" as plain text
const buttons = w.findAll('button')
expect(buttons.length).toBe(1) // just "Home"
expect(w.text()).toContain('OnlyFolder')
})
it('collapses >4 segments with ellipsis, preserving first and last two', () => {
const segments = [
seg('a', 'A'), seg('b', 'B'), seg('c', 'C'),
seg('d', 'D'), seg('e', 'E'),
]
const w = mount(FolderBreadcrumb, { props: { segments } })
const text = w.text()
expect(text).toContain('A') // first preserved
expect(text).toContain('…') // ellipsis present
expect(text).toContain('D') // second-to-last preserved
expect(text).toContain('E') // last preserved
expect(text).not.toContain('B') // middle segments collapsed
expect(text).not.toContain('C')
})
it('3 segments: all rendered without ellipsis', () => {
const segments = [seg('a', 'A'), seg('b', 'B'), seg('c', 'C')]
const w = mount(FolderBreadcrumb, { props: { segments } })
const text = w.text()
expect(text).toContain('A')
expect(text).toContain('B')
expect(text).toContain('C')
expect(text).not.toContain('…')
})
it('deep 3-level path: clicking middle segment navigates correctly', async () => {
const segments = [seg('root', 'Root'), seg('mid', 'Mid'), seg('cur', 'Current')]
const w = mount(FolderBreadcrumb, { props: { segments } })
const buttons = w.findAll('button')
// buttons[0] = Home, buttons[1] = Root, buttons[2] = Mid
await buttons[2].trigger('click')
const events = w.emitted('navigate') || []
const midClicks = events.filter(e => e[0] === 'mid')
expect(midClicks.length).toBe(1)
})
})
@@ -0,0 +1,175 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import FolderTreeItem from '../FolderTreeItem.vue'
const mockListFolders = vi.fn()
vi.mock('../../../api/client.js', () => ({
listFolders: (...a) => mockListFolders(...a),
}))
// Minimal router so router-link renders correctly
function makeRouter(currentPath = '/') {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div/>' } },
{ path: '/folders/:folderId', component: { template: '<div/>' } },
],
})
}
function makeFolder(overrides = {}) {
return {
id: overrides.id ?? 'f1',
name: overrides.name ?? 'Test',
parent_id: overrides.parent_id ?? null,
has_children: overrides.has_children ?? false,
created_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
async function mountItem(folderOverrides = {}, routerPath = '/') {
setActivePinia(createPinia())
const router = makeRouter(routerPath)
await router.push(routerPath)
await router.isReady()
return mount(FolderTreeItem, {
props: { folder: makeFolder(folderOverrides), depth: 1 },
global: { plugins: [router] },
})
}
describe('FolderTreeItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
})
// ── Expand arrow visibility ───────────────────────────────────────────────
it('hides expand arrow when has_children is false', async () => {
const w = await mountItem({ has_children: false })
// The expand button should not be rendered
const expandBtn = w.find('button')
expect(expandBtn.exists()).toBe(false)
})
it('shows expand arrow when has_children is true', async () => {
const w = await mountItem({ has_children: true })
const expandBtn = w.find('button')
expect(expandBtn.exists()).toBe(true)
})
it('shows expand arrow when has_children is undefined (unknown)', async () => {
// Newly created folders from optimistic push lack has_children
const folder = { id: 'f1', name: 'New', parent_id: null, created_at: '2026-01-01T00:00:00Z' }
setActivePinia(createPinia())
const router = makeRouter('/')
await router.push('/')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder, depth: 1 },
global: { plugins: [router] },
})
// has_children undefined → arrow shown (conservative: assume could have children)
const expandBtn = w.find('button')
expect(expandBtn.exists()).toBe(true)
})
// ── Router-link href ──────────────────────────────────────────────────────
it('router-link points to /folders/<id>', async () => {
const w = await mountItem({ id: 'abc-123' })
const link = w.find('a')
expect(link.attributes('href')).toBe('/folders/abc-123')
})
// ── Active state ─────────────────────────────────────────────────────────
it('router-link has active class when route matches folder id', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/folders/f1')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder: makeFolder({ id: 'f1' }), depth: 1 },
global: { plugins: [router] },
})
const link = w.find('a')
expect(link.classes()).toContain('bg-indigo-50')
})
it('router-link does NOT have active class when route does not match', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/folders/other-folder')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder: makeFolder({ id: 'f1' }), depth: 1 },
global: { plugins: [router] },
})
const link = w.find('a')
expect(link.classes()).not.toContain('bg-indigo-50')
})
// ── Expand / collapse children ────────────────────────────────────────────
it('clicking expand arrow loads children from API', async () => {
const children = [makeFolder({ id: 'child-1', name: 'Child', parent_id: 'f1' })]
mockListFolders.mockResolvedValue({ items: children })
const w = await mountItem({ id: 'f1', has_children: true })
const btn = w.find('button')
await btn.trigger('click')
await w.vm.$nextTick()
expect(mockListFolders).toHaveBeenCalledWith('f1')
})
it('children are rendered after expand', async () => {
const children = [makeFolder({ id: 'child-1', name: 'ChildFolder', parent_id: 'f1' })]
mockListFolders.mockResolvedValue({ items: children })
const w = await mountItem({ id: 'f1', has_children: true })
await w.find('button').trigger('click')
await w.vm.$nextTick()
expect(w.text()).toContain('ChildFolder')
})
it('expand arrow disappears after loading empty children', async () => {
mockListFolders.mockResolvedValue({ items: [] })
const w = await mountItem({ id: 'f1', has_children: true })
await w.find('button').trigger('click')
await w.vm.$nextTick()
// After loading empty children, button should be gone
expect(w.find('button').exists()).toBe(false)
})
// ── Depth indentation ────────────────────────────────────────────────────
it('applies correct left padding based on depth', async () => {
setActivePinia(createPinia())
const router = makeRouter('/')
await router.push('/')
await router.isReady()
const w = mount(FolderTreeItem, {
props: { folder: makeFolder(), depth: 3 },
global: { plugins: [router] },
})
const row = w.find('.flex.items-center')
expect(row.attributes('style')).toContain('padding-left: 36px')
})
})
+43 -41
View File
@@ -2,24 +2,12 @@
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
<!-- Logo -->
<div class="px-6 py-5 border-b border-gray-100">
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocScanner</h1>
<p class="text-xs text-gray-400 mt-0.5">AI Document Classifier</p>
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocuVault</h1>
<p class="text-xs text-gray-400 mt-0.5">Document Manager</p>
</div>
<!-- Nav -->
<nav class="flex-1 px-3 py-4 overflow-y-auto">
<router-link
to="/"
class="nav-link"
:class="{ 'nav-link-active': $route.path === '/' }"
>
<svg class="w-4 h-4 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</router-link>
<router-link
to="/topics"
class="nav-link"
@@ -32,7 +20,7 @@
All Topics
</router-link>
<!-- Shared with me entry -->
<!-- Shared with me -->
<router-link
to="/shared"
class="nav-link"
@@ -53,20 +41,32 @@
</span>
</router-link>
<!-- Folders section -->
<!-- Folders root + collapsible tree -->
<div class="mt-3">
<div class="flex items-center justify-between px-3 mb-1">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Folders</p>
<!-- "Folders" is the root entry clicking navigates to the root folder view -->
<div class="flex items-center justify-between">
<router-link
to="/"
class="nav-link flex-1"
:class="{ 'nav-link-active': $route.path === '/' || $route.path.startsWith('/folders/') }"
>
<svg class="w-4 h-4 mr-2 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
Folders
</router-link>
<button
@click="startNewFolder"
class="text-xs text-indigo-600 hover:underline"
class="text-xs text-indigo-600 hover:underline shrink-0 mr-1"
title="New root folder"
>
New folder
New
</button>
</div>
<!-- New folder inline input -->
<div v-if="showNewFolderInput" class="px-3 mb-2">
<!-- Inline new root folder input -->
<div v-if="showNewFolderInput" class="px-3 mb-2 mt-1">
<input
v-model="newFolderName"
type="text"
@@ -79,21 +79,16 @@
<p v-if="newFolderError" class="text-red-500 text-xs mt-1">{{ newFolderError }}</p>
</div>
<!-- Folder list -->
<div v-if="foldersStore.loading && foldersStore.folders.length === 0" class="px-3 py-1 text-xs text-gray-400">Loading</div>
<router-link
v-for="folder in foldersStore.folders"
<!-- Sub-folders tree (indented under Folders) -->
<div v-if="loadingRoots" class="pl-7 py-1 text-xs text-gray-400">Loading</div>
<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
<FolderTreeItem
v-for="folder in foldersStore.rootFolders"
:key="folder.id"
:to="`/folders/${folder.id}`"
class="nav-link text-sm"
:class="{ 'nav-link-active': $route.params.folderId === folder.id || $route.params.folderId === String(folder.id) }"
>
<svg class="w-4 h-4 mr-2 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<span class="truncate flex-1">{{ folder.name }}</span>
</router-link>
:folder="folder"
:depth="1"
/>
</div>
<!-- Topics list -->
@@ -118,7 +113,7 @@
</div>
</nav>
<!-- Quota bar (between topics nav and settings footer, UI-SPEC Phase 3) -->
<!-- Quota bar -->
<QuotaBar />
<!-- Settings + Admin link -->
@@ -172,27 +167,34 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTopicsStore } from '../../stores/topics.js'
import { useAuthStore } from '../../stores/auth.js'
import { useFoldersStore } from '../../stores/folders.js'
import QuotaBar from './QuotaBar.vue'
import FolderTreeItem from '../folders/FolderTreeItem.vue'
import * as api from '../../api/client.js'
const topicsStore = useTopicsStore()
const authStore = useAuthStore()
const foldersStore = useFoldersStore()
const router = useRouter()
const route = useRoute()
const sharedCount = ref(0)
const showNewFolderInput = ref(false)
const newFolderName = ref('')
const newFolderError = ref('')
const loadingRoots = ref(true)
watch(() => foldersStore.treeVersion, () => foldersStore.fetchRootFolders())
onMounted(async () => {
await foldersStore.fetchFolders(null)
try {
await foldersStore.fetchRootFolders()
} finally {
loadingRoots.value = false
}
try {
const data = await api.getSharedWithMe()
const items = Array.isArray(data) ? data : (data.items ?? [])
+3
View File
@@ -7,4 +7,7 @@ import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// Wait for the initial navigation guard (token refresh) to complete before mounting
// so onMounted hooks in App.vue and child components have a valid access token.
await router.isReady()
app.mount('#app')
+4 -4
View File
@@ -1,13 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
import HomeView from '../views/HomeView.vue'
import FileManagerView from '../views/FileManagerView.vue'
import TopicsView from '../views/TopicsView.vue'
import DocumentView from '../views/DocumentView.vue'
import SettingsView from '../views/SettingsView.vue'
const routes = [
// Existing routes
{ path: '/', component: HomeView },
// File manager is the home — handles both root and folder views
{ path: '/', component: FileManagerView },
{ path: '/topics', component: TopicsView },
{ path: '/topics/:name', component: TopicsView },
{ path: '/document/:id', component: DocumentView },
@@ -43,7 +43,7 @@ const routes = [
{
path: '/folders/:folderId',
name: 'folder',
component: () => import('../views/FolderView.vue'),
component: FileManagerView,
meta: { requiresAuth: true },
},
{
@@ -0,0 +1,220 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFoldersStore } from '../folders.js'
// ── API mock ──────────────────────────────────────────────────────────────────
const mockListFolders = vi.fn()
const mockCreateFolder = vi.fn()
const mockGetFolder = vi.fn()
const mockRenameFolder = vi.fn()
const mockDeleteFolder = vi.fn()
vi.mock('../../api/client.js', () => ({
listFolders: (...a) => mockListFolders(...a),
createFolder: (...a) => mockCreateFolder(...a),
getFolder: (...a) => mockGetFolder(...a),
renameFolder: (...a) => mockRenameFolder(...a),
deleteFolder: (...a) => mockDeleteFolder(...a),
moveDocument: vi.fn(),
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeFolder(overrides = {}) {
return {
id: overrides.id ?? 'folder-1',
name: overrides.name ?? 'Test',
parent_id: overrides.parent_id ?? null,
has_children: overrides.has_children ?? false,
created_at: '2026-01-01T00:00:00Z',
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('foldersStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
// ── fetchFolders ─────────────────────────────────────────────────────────
it('fetchFolders(null) sets folders and rootFolders', async () => {
const roots = [makeFolder({ id: 'r1', name: 'Root1' })]
mockListFolders.mockResolvedValue({ items: roots })
const store = useFoldersStore()
await store.fetchFolders(null)
expect(store.folders).toEqual(roots)
expect(store.rootFolders).toEqual(roots)
})
it('fetchFolders(parentId) sets folders but NOT rootFolders', async () => {
const children = [makeFolder({ id: 'c1', name: 'Child', parent_id: 'parent-1' })]
mockListFolders.mockResolvedValue({ items: children })
const store = useFoldersStore()
store.rootFolders = [makeFolder({ id: 'r1' })] // pre-populate rootFolders
await store.fetchFolders('parent-1')
expect(store.folders).toEqual(children)
expect(store.rootFolders).toEqual([makeFolder({ id: 'r1' })]) // unchanged
})
it('fetchRootFolders sets rootFolders without touching folders', async () => {
const roots = [makeFolder({ id: 'r1' })]
mockListFolders.mockResolvedValue({ items: roots })
const store = useFoldersStore()
store.folders = [makeFolder({ id: 'sub1', parent_id: 'r1' })] // simulate being inside a folder
await store.fetchRootFolders()
expect(store.rootFolders).toEqual(roots)
expect(store.folders[0].id).toBe('sub1') // unchanged
})
// ── createFolder ─────────────────────────────────────────────────────────
it('createFolder(name, null) adds to both folders and rootFolders', async () => {
const newFolder = makeFolder({ id: 'new-1', name: 'NewRoot' })
mockCreateFolder.mockResolvedValue(newFolder)
const store = useFoldersStore()
await store.createFolder('NewRoot', null)
expect(store.folders.some(f => f.id === 'new-1')).toBe(true)
expect(store.rootFolders.some(f => f.id === 'new-1')).toBe(true)
})
it('createFolder(name, parentId) adds to folders but NOT rootFolders', async () => {
const newFolder = makeFolder({ id: 'sub-1', name: 'SubFolder', parent_id: 'parent-1' })
mockCreateFolder.mockResolvedValue(newFolder)
const store = useFoldersStore()
await store.createFolder('SubFolder', 'parent-1')
expect(store.folders.some(f => f.id === 'sub-1')).toBe(true)
expect(store.rootFolders.some(f => f.id === 'sub-1')).toBe(false)
})
it('createFolder bumps treeVersion', async () => {
mockCreateFolder.mockResolvedValue(makeFolder())
const store = useFoldersStore()
const before = store.treeVersion
await store.createFolder('Test', null)
expect(store.treeVersion).toBe(before + 1)
})
it('createFolder throws on API error and propagates', async () => {
mockCreateFolder.mockRejectedValue(new Error('409 Conflict'))
const store = useFoldersStore()
await expect(store.createFolder('Dup', null)).rejects.toThrow('409 Conflict')
})
// ── renameFolder ─────────────────────────────────────────────────────────
it('renameFolder updates folder in both folders and rootFolders', async () => {
const original = makeFolder({ id: 'f1', name: 'OldName' })
const updated = { ...original, name: 'NewName' }
mockRenameFolder.mockResolvedValue(updated)
const store = useFoldersStore()
store.folders = [original]
store.rootFolders = [original]
await store.renameFolder('f1', 'NewName')
expect(store.folders[0].name).toBe('NewName')
expect(store.rootFolders[0].name).toBe('NewName')
})
it('renameFolder bumps treeVersion', async () => {
const folder = makeFolder({ id: 'f1' })
mockRenameFolder.mockResolvedValue({ ...folder, name: 'New' })
const store = useFoldersStore()
store.folders = [folder]
const before = store.treeVersion
await store.renameFolder('f1', 'New')
expect(store.treeVersion).toBe(before + 1)
})
// ── deleteFolder ─────────────────────────────────────────────────────────
it('deleteFolder removes from both folders and rootFolders', async () => {
mockDeleteFolder.mockResolvedValue(null)
const store = useFoldersStore()
const folder = makeFolder({ id: 'del-1' })
store.folders = [folder]
store.rootFolders = [folder]
await store.deleteFolder('del-1')
expect(store.folders.some(f => f.id === 'del-1')).toBe(false)
expect(store.rootFolders.some(f => f.id === 'del-1')).toBe(false)
})
it('deleteFolder bumps treeVersion', async () => {
mockDeleteFolder.mockResolvedValue(null)
const store = useFoldersStore()
store.folders = [makeFolder({ id: 'del-1' })]
const before = store.treeVersion
await store.deleteFolder('del-1')
expect(store.treeVersion).toBe(before + 1)
})
// ── navigateTo ───────────────────────────────────────────────────────────
it('navigateTo(null) clears folders and breadcrumb', async () => {
const store = useFoldersStore()
store.folders = [makeFolder()]
store.breadcrumb = [{ id: 'x', name: 'X' }]
await store.navigateTo(null)
expect(store.folders).toEqual([])
expect(store.breadcrumb).toEqual([])
})
it('navigateTo(id) sets breadcrumb from API', async () => {
const crumbs = [{ id: 'r1', name: 'Root' }, { id: 'f1', name: 'Test' }]
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: crumbs })
const store = useFoldersStore()
await store.navigateTo('f1')
expect(store.breadcrumb).toEqual(crumbs)
})
it('navigateTo(id) sets breadcrumb to [] on API error', async () => {
mockGetFolder.mockRejectedValue(new Error('404'))
const store = useFoldersStore()
await store.navigateTo('bad-id')
expect(store.breadcrumb).toEqual([])
})
it('navigateTo sets currentFolderId', async () => {
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
const store = useFoldersStore()
await store.navigateTo('f1')
expect(store.currentFolderId).toBe('f1')
})
})
+19 -5
View File
@@ -54,16 +54,17 @@ export const useDocumentsStore = defineStore('documents', () => {
}
/**
* Three-step upload:
* Three-step upload with optional folder placement:
* Step 1: POST /api/documents/upload-url → {upload_url, document_id} (0% → 5%)
* Step 2: XHR PUT bytes directly to MinIO presigned URL (5% → 90%)
* Step 3: POST /api/documents/{id}/confirm → {id, size_bytes, used_bytes, status} (92% → 100%)
* Step 3: POST /api/documents/{id}/confirm → {id, size_bytes, used_bytes, status} (92% → 96%)
* Step 3b: PATCH /api/documents/{id}/folder if folderId is non-null (96% → 100%)
*
* On 413 quota exceeded: err.payload = {used_bytes, limit_bytes, rejected_bytes} from confirm step.
* On success: authStore.fetchQuota() is called to refresh sidebar quota bar (STORE-04).
* Returns { rowKey, doc } on success so callers can clear the progress entry.
*/
async function upload(file, autoClassify = true) {
async function upload(file, autoClassify = true, folderId = null) {
const authStore = useAuthStore()
// Composite key prevents collision when same filename uploaded twice (T-03-25)
const rowKey = `${file.name}__${Date.now()}`
@@ -83,8 +84,14 @@ export const useDocumentsStore = defineStore('documents', () => {
})
uploadProgress.value[rowKey] = 92
// Step 3: confirm (UI-SPEC: 92% → 100%, status "Processing…" → "Done — classifying…")
// Step 3: confirm (UI-SPEC: 92% → 96%, status "Processing…" → "Done — classifying…")
const doc = await api.confirmUpload(document_id)
uploadProgress.value[rowKey] = 96
// Step 3b: move to target folder if the upload was initiated inside a folder
if (folderId != null) {
await api.moveDocument(doc.id, folderId)
}
uploadProgress.value[rowKey] = 100
documents.value.unshift({
@@ -94,6 +101,7 @@ export const useDocumentsStore = defineStore('documents', () => {
mime_type: file.type,
size_bytes: doc.size_bytes,
topics: [],
folder_id: folderId,
created_at: new Date().toISOString(),
classified_at: null,
})
@@ -142,6 +150,12 @@ export const useDocumentsStore = defineStore('documents', () => {
}, 300)
})
async function moveToFolder(docId, folderId) {
await api.moveDocument(docId, folderId)
documents.value = documents.value.filter(d => d.id !== docId)
total.value = Math.max(0, total.value - 1)
}
async function shareDocument(docId, recipientHandle) {
try { return await api.createShare(docId, recipientHandle) } catch (e) { throw e }
}
@@ -154,5 +168,5 @@ export const useDocumentsStore = defineStore('documents', () => {
try { return await api.listShares(docId) } catch (e) { throw e }
}
return { documents, total, loading, error, uploadProgress, currentFolderId, searchQuery, sortField, sortOrder, fetchDocuments, upload, remove, reclassify, shareDocument, revokeShare, listShares }
return { documents, total, loading, error, uploadProgress, currentFolderId, searchQuery, sortField, sortOrder, fetchDocuments, upload, remove, reclassify, moveToFolder, shareDocument, revokeShare, listShares }
})
+21 -2
View File
@@ -4,17 +4,21 @@ import * as api from '../api/client.js'
export const useFoldersStore = defineStore('folders', () => {
const folders = ref([])
const rootFolders = ref([]) // root-level folders for sidebar tree and folder pickers
const currentFolderId = ref(null)
const breadcrumb = ref([])
const loading = ref(false)
const error = ref(null)
const treeVersion = ref(0) // bumped on any mutation so sidebar tree can react
async function fetchFolders(parentId = null) {
loading.value = true
error.value = null
try {
const data = await api.listFolders(parentId)
folders.value = data.items ?? data
const list = data.items ?? data
folders.value = list
if (parentId === null) rootFolders.value = list
} catch (e) {
error.value = e.message || 'Failed to load folders'
} finally {
@@ -22,12 +26,21 @@ export const useFoldersStore = defineStore('folders', () => {
}
}
async function fetchRootFolders() {
try {
const data = await api.listFolders(null)
rootFolders.value = data.items ?? data
} catch {}
}
async function createFolder(name, parentId = null) {
loading.value = true
error.value = null
try {
const folder = await api.createFolder(name, parentId)
folders.value.push(folder)
if (parentId === null) rootFolders.value.push(folder)
treeVersion.value++
return folder
} catch (e) {
error.value = e.message || 'Failed to create folder'
@@ -44,6 +57,9 @@ export const useFoldersStore = defineStore('folders', () => {
const updated = await api.renameFolder(folderId, name)
const idx = folders.value.findIndex(f => f.id === folderId)
if (idx !== -1) folders.value[idx] = updated
const rootIdx = rootFolders.value.findIndex(f => f.id === folderId)
if (rootIdx !== -1) rootFolders.value[rootIdx] = updated
treeVersion.value++
return updated
} catch (e) {
error.value = e.message || 'Failed to rename folder'
@@ -59,6 +75,8 @@ export const useFoldersStore = defineStore('folders', () => {
try {
await api.deleteFolder(folderId)
folders.value = folders.value.filter(f => f.id !== folderId)
rootFolders.value = rootFolders.value.filter(f => f.id !== folderId)
treeVersion.value++
} catch (e) {
error.value = e.message || 'Failed to delete folder'
throw e
@@ -78,8 +96,9 @@ export const useFoldersStore = defineStore('folders', () => {
}
} else {
breadcrumb.value = []
folders.value = [] // clear stale subfolder list when returning to home
}
}
return { folders, currentFolderId, breadcrumb, loading, error, fetchFolders, createFolder, renameFolder, deleteFolder, navigateTo }
return { folders, rootFolders, currentFolderId, breadcrumb, loading, error, treeVersion, fetchFolders, fetchRootFolders, createFolder, renameFolder, deleteFolder, navigateTo }
})
+450
View File
@@ -0,0 +1,450 @@
<template>
<div class="flex flex-col h-full">
<!-- Sticky 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 flex-wrap">
<FolderBreadcrumb
:segments="foldersStore.breadcrumb"
@navigate="handleBreadcrumbNavigate"
/>
<div class="ml-auto flex items-center gap-2 shrink-0">
<SearchBar v-if="currentFolderId" v-model="docsStore.searchQuery" />
<SortControls
v-if="currentFolderId"
:sort="docsStore.sortField"
:order="docsStore.sortOrder"
@change="handleSortChange"
/>
<button
@click="startNewFolder"
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 rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New folder
</button>
</div>
</div>
</div>
<!-- Folder view -->
<div class="flex-1 overflow-y-auto flex flex-col">
<!-- Upload zone (always at top of folder view) -->
<div class="px-6 pt-5 pb-3">
<DropZone @files-selected="onFilesSelected" />
<UploadProgress :items="uploadQueue" />
</div>
<!-- Column headers -->
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none">
<span></span>
<span>Name</span>
<span class="text-right hidden md:block">Size</span>
<span class="text-right hidden sm:block">Modified</span>
<span></span>
</div>
<!-- New-folder inline row -->
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</div>
<div class="flex items-center gap-2 col-span-4">
<input
ref="newFolderInputRef"
v-model="newFolderName"
type="text"
placeholder="Folder name"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitNewFolder"
@keydown.escape="cancelNewFolder"
/>
<button @click="submitNewFolder" class="text-sm text-indigo-600 hover:underline font-medium">Save</button>
<button @click="cancelNewFolder" class="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
<p v-if="newFolderError" class="text-xs text-red-500">{{ newFolderError }}</p>
</div>
</div>
<!-- Item list -->
<div class="mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<!-- Folder rows -------------------------------------------------- -->
<div
v-for="folder in foldersStore.folders"
:key="`f-${folder.id}`"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
:class="{
'bg-amber-50 ring-2 ring-inset ring-amber-300': docDragOverFolderId === folder.id,
'bg-gray-50': renaming === folder.id,
}"
@click="renaming === folder.id ? null : navigateToFolder(folder.id)"
@dragover.prevent="onFolderDragOver(folder.id, $event)"
@dragleave="onFolderDragLeave"
@drop.prevent="onDropDocOnFolder(folder.id)"
>
<!-- Folder icon -->
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</div>
<!-- Name / rename input -->
<div>
<input
v-if="renaming === folder.id"
v-model="renameValue"
class="border border-indigo-300 rounded-lg px-2 py-0.5 text-sm w-full max-w-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitRename(folder.id)"
@keydown.escape="cancelRename"
@vue:mounted="e => e.el?.focus()"
/>
<span v-else class="text-sm font-medium text-gray-900 truncate block">{{ folder.name }}</span>
</div>
<span class="text-right text-xs text-gray-400 hidden md:block"></span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
<!-- Hover actions -->
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<button @click.stop="startRename(folder)" title="Rename"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click.stop="folderToDelete = folder" title="Delete folder"
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Document rows ------------------------------------------------ -->
<div
v-for="doc in docsStore.documents"
:key="`d-${doc.id}`"
draggable="true"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
:class="{ 'opacity-50': docDragging?.id === doc.id }"
@click="$router.push(`/document/${doc.id}`)"
@dragstart="onDocDragStart(doc, $event)"
@dragend="docDragging = null; docDragOverFolderId = null"
>
<!-- File icon -->
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<!-- Name + topics -->
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ doc.original_name }}</p>
<div v-if="doc.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
<TopicBadge v-for="t in doc.topics.slice(0, 3)" :key="t" :name="t" :color="topicColor(t)" />
</div>
</div>
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(doc.size_bytes) }}</span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(doc.created_at) }}</span>
<!-- Hover actions -->
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<!-- Share -->
<button @click.stop="shareDoc = doc" title="Share"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<!-- Move -->
<div class="relative">
<button
@click.stop="folderPickerDocId = folderPickerDocId === doc.id ? null : doc.id"
title="Move to folder"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
<div
v-if="folderPickerDocId === doc.id"
class="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-xl shadow-lg z-20 py-1"
@click.stop
>
<button class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
@click.stop="doMove(doc.id, null)">Root (no folder)</button>
<button
v-for="f in foldersStore.rootFolders"
:key="f.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 truncate"
@click.stop="doMove(doc.id, f.id)"
>{{ f.name }}</button>
<p v-if="!foldersStore.rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
</div>
</div>
<!-- Delete -->
<button @click.stop="doDeleteDoc(doc.id)" title="Delete"
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Empty state (no search active) -->
<div
v-if="!docsStore.loading && !foldersStore.loading && foldersStore.folders.length === 0 && docsStore.documents.length === 0 && !showNewFolderInput"
class="px-4 py-10 text-center text-gray-300"
>
<p class="text-gray-400 text-sm">{{ currentFolderId ? 'This folder is empty' : 'No folders yet' }}</p>
<p class="text-xs mt-1">{{ currentFolderId ? 'Upload files above or create a sub-folder' : 'Create a folder to get started' }}</p>
</div>
<!-- No results for search -->
<div
v-else-if="docsStore.searchQuery && docsStore.documents.length === 0 && foldersStore.folders.length === 0"
class="px-4 py-10 text-center text-sm text-gray-400"
>
No items match "{{ docsStore.searchQuery }}".
</div>
<!-- Loading -->
<div v-if="docsStore.loading || foldersStore.loading" class="py-6 text-center text-sm text-gray-400">
Loading
</div>
</div>
</div>
<!-- Modals -->
<FolderDeleteModal
v-if="folderToDelete"
:folder="folderToDelete"
@confirm="confirmDeleteFolder"
@cancel="folderToDelete = null"
/>
<ShareModal
v-if="shareDoc"
:doc="shareDoc"
@close="shareDoc = null"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFoldersStore } from '../stores/folders.js'
import { useDocumentsStore } from '../stores/documents.js'
import { useTopicsStore } from '../stores/topics.js'
import FolderBreadcrumb from '../components/folders/FolderBreadcrumb.vue'
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
import SearchBar from '../components/documents/SearchBar.vue'
import SortControls from '../components/documents/SortControls.vue'
import TopicBadge from '../components/topics/TopicBadge.vue'
import UploadProgress from '../components/upload/UploadProgress.vue'
import DropZone from '../components/upload/DropZone.vue'
import ShareModal from '../components/sharing/ShareModal.vue'
const route = useRoute()
const router = useRouter()
const foldersStore = useFoldersStore()
const docsStore = useDocumentsStore()
const topicsStore = useTopicsStore()
// ── Navigation ────────────────────────────────────────────────────────────────
const currentFolderId = computed(() => route.params.folderId ?? null)
async function loadFolder(folderId) {
if (folderId === null) {
foldersStore.navigateTo(null)
await foldersStore.fetchFolders(null)
return
}
await foldersStore.navigateTo(folderId)
await Promise.all([
foldersStore.fetchFolders(folderId),
docsStore.fetchDocuments({ folderId, sort: docsStore.sortField, order: docsStore.sortOrder }),
])
docsStore.currentFolderId = folderId
}
onMounted(() => loadFolder(currentFolderId.value))
watch(currentFolderId, (id) => loadFolder(id))
function navigateToFolder(id) { router.push(`/folders/${id}`) }
function handleBreadcrumbNavigate(id) {
if (id == null) router.push('/')
else router.push(`/folders/${id}`)
}
function handleSortChange({ sort, order }) {
docsStore.sortField = sort
docsStore.sortOrder = order
docsStore.fetchDocuments({ folderId: currentFolderId.value, sort, order })
}
// ── Upload ────────────────────────────────────────────────────────────────────
const uploadQueue = ref([])
async function onFilesSelected({ files, autoClassify }) {
const folderId = currentFolderId.value
const promises = files.map(file => {
const item = reactive({ name: file.name, done: false, error: null, quotaError: null, topics: null })
uploadQueue.value.unshift(item)
return docsStore.upload(file, autoClassify, folderId)
.then(({ doc }) => { item.done = true; item.topics = doc.topics ?? [] })
.catch(e => {
if (e.status === 413 && e.payload) item.quotaError = e.payload
else item.error = e.message
})
})
await Promise.allSettled(promises)
await topicsStore.fetchTopics()
}
// ── Drag-and-drop: move documents onto folders ────────────────────────────────
const docDragging = ref(null) // doc object being dragged
const docDragOverFolderId = ref(null) // folder.id the cursor is over
function onDocDragStart(doc, e) {
docDragging.value = doc
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', doc.id)
}
function onFolderDragOver(folderId, e) {
if (!docDragging.value) return
e.preventDefault()
docDragOverFolderId.value = folderId
}
function onFolderDragLeave() {
docDragOverFolderId.value = null
}
async function onDropDocOnFolder(folderId) {
if (!docDragging.value) return
const docId = docDragging.value.id
docDragging.value = null
docDragOverFolderId.value = null
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
}
// ── New folder ────────────────────────────────────────────────────────────────
const showNewFolderInput = ref(false)
const newFolderName = ref('')
const newFolderError = ref('')
const newFolderInputRef = ref(null)
function startNewFolder() {
newFolderName.value = ''
newFolderError.value = ''
showNewFolderInput.value = true
nextTick(() => newFolderInputRef.value?.focus())
}
function cancelNewFolder() { showNewFolderInput.value = false; newFolderError.value = '' }
async function submitNewFolder() {
const name = newFolderName.value.trim()
if (!name) { newFolderError.value = 'Folder name cannot be empty.'; return }
try {
await foldersStore.createFolder(name, currentFolderId.value)
showNewFolderInput.value = false
newFolderError.value = ''
} catch (e) {
newFolderError.value = e.message || 'Failed to create folder.'
}
}
// ── Rename folder ─────────────────────────────────────────────────────────────
const renaming = ref(null)
const renameValue = ref('')
function startRename(folder) {
renaming.value = folder.id
renameValue.value = folder.name
}
function cancelRename() { renaming.value = null }
async function submitRename(folderId) {
const name = renameValue.value.trim()
if (!name) { cancelRename(); return }
try { await foldersStore.renameFolder(folderId, name) }
finally { renaming.value = null }
}
// ── Delete folder ─────────────────────────────────────────────────────────────
const folderToDelete = ref(null)
async function confirmDeleteFolder() {
if (!folderToDelete.value) return
try { await foldersStore.deleteFolder(folderToDelete.value.id) }
finally { folderToDelete.value = null }
}
// ── Document actions ──────────────────────────────────────────────────────────
const shareDoc = ref(null)
const folderPickerDocId = ref(null)
async function doMove(docId, folderId) {
folderPickerDocId.value = null
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
}
async function doDeleteDoc(docId) {
try { await docsStore.remove(docId) } catch (e) { console.error(e.message) }
}
function onOutsideClick(e) {
if (!e.target.closest('.relative')) folderPickerDocId.value = null
}
onMounted(() => document.addEventListener('click', onOutsideClick))
onUnmounted(() => document.removeEventListener('click', onOutsideClick))
// ── Helpers ───────────────────────────────────────────────────────────────────
function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
}
function formatDate(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function formatSize(bytes) {
if (!bytes) return '—'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
</script>
@@ -0,0 +1,444 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import FileManagerView from '../FileManagerView.vue'
// ── API mock ──────────────────────────────────────────────────────────────────
const mockListFolders = vi.fn()
const mockListDocuments = vi.fn()
const mockGetFolder = vi.fn()
const mockCreateFolder = vi.fn()
const mockRenameFolder = vi.fn()
const mockDeleteFolder = vi.fn()
const mockMoveDocument = vi.fn()
vi.mock('../../api/client.js', () => ({
listFolders: (...a) => mockListFolders(...a),
listDocuments: (...a) => mockListDocuments(...a),
getFolder: (...a) => mockGetFolder(...a),
createFolder: (...a) => mockCreateFolder(...a),
renameFolder: (...a) => mockRenameFolder(...a),
deleteFolder: (...a) => mockDeleteFolder(...a),
moveDocument: (...a) => mockMoveDocument(...a),
deleteDocument: vi.fn().mockResolvedValue(null),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listTopics: vi.fn().mockResolvedValue([]),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
// Stub heavy child components so we only test FileManagerView logic
vi.mock('../../components/folders/FolderBreadcrumb.vue', () => ({
default: { template: '<nav><slot/></nav>', props: ['segments'], emits: ['navigate'] },
}))
vi.mock('../../components/upload/DropZone.vue', () => ({
default: { template: '<div class="dropzone"/>', emits: ['files-selected'] },
}))
vi.mock('../../components/upload/UploadProgress.vue', () => ({
default: { template: '<div/>', props: ['items'] },
}))
vi.mock('../../components/folders/FolderDeleteModal.vue', () => ({
default: { template: '<div/>', props: ['folder'], emits: ['confirm', 'cancel'] },
}))
vi.mock('../../components/sharing/ShareModal.vue', () => ({
default: { template: '<div/>', props: ['doc'], emits: ['close'] },
}))
vi.mock('../../components/documents/SearchBar.vue', () => ({
default: { template: '<input/>', props: ['modelValue'] },
}))
vi.mock('../../components/documents/SortControls.vue', () => ({
default: { template: '<div/>', props: ['sort', 'order'], emits: ['change'] },
}))
vi.mock('../../components/topics/TopicBadge.vue', () => ({
default: { template: '<span/>', props: ['name', 'color'] },
}))
vi.mock('../../stores/auth.js', () => ({
useAuthStore: () => ({
user: { email: 'test@example.com', role: 'user' },
accessToken: 'fake-token',
fetchQuota: vi.fn().mockResolvedValue(null),
}),
}))
vi.mock('../../stores/topics.js', () => ({
useTopicsStore: () => ({
topics: [],
loading: false,
fetchTopics: vi.fn().mockResolvedValue(null),
}),
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeFolder(overrides = {}) {
return {
id: overrides.id ?? 'f1',
name: overrides.name ?? 'TestFolder',
parent_id: overrides.parent_id ?? null,
has_children: overrides.has_children ?? false,
created_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
function makeDoc(overrides = {}) {
return {
id: overrides.id ?? 'd1',
original_name: overrides.original_name ?? 'test.pdf',
filename: 'test.pdf',
mime_type: 'application/pdf',
size_bytes: 1024,
topics: [],
folder_id: overrides.folder_id ?? 'f1',
created_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: FileManagerView },
{ path: '/folders/:folderId', component: FileManagerView },
{ path: '/document/:id', component: { template: '<div/>' } },
],
})
}
async function mountView(path = '/') {
setActivePinia(createPinia())
const router = makeRouter()
await router.push(path)
await router.isReady()
const w = mount(FileManagerView, {
global: {
plugins: [router],
stubs: { QuotaBar: true, AppSpinner: true },
},
})
await flushPromises()
return { w, router }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('FileManagerView — root navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
})
it('at root (/), fetches root folders via listFolders(null)', async () => {
const roots = [makeFolder({ id: 'r1', name: 'Root1' })]
mockListFolders.mockResolvedValue({ items: roots })
await mountView('/')
expect(mockListFolders).toHaveBeenCalledWith(null)
})
it('at root, renders root folders in the item list', async () => {
mockListFolders.mockResolvedValue({ items: [makeFolder({ id: 'r1', name: 'MyFolder' })] })
const { w } = await mountView('/')
expect(w.text()).toContain('MyFolder')
})
it('root view does NOT call listDocuments', async () => {
await mountView('/')
expect(mockListDocuments).not.toHaveBeenCalled()
})
})
describe('FileManagerView — folder navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [{ id: 'f1', name: 'Test' }] })
})
it('at /folders/:id, fetches that folder\'s children', async () => {
const children = [makeFolder({ id: 'c1', name: 'Child', parent_id: 'f1' })]
mockListFolders.mockResolvedValue({ items: children })
await mountView('/folders/f1')
expect(mockListFolders).toHaveBeenCalledWith('f1')
})
it('at /folders/:id, fetches documents for that folder', async () => {
await mountView('/folders/f1')
expect(mockListDocuments).toHaveBeenCalledWith(
expect.objectContaining({ folderId: 'f1' })
)
})
it('renders subfolder rows', async () => {
mockListFolders.mockResolvedValue({
items: [makeFolder({ id: 'sub1', name: 'SubFolder', parent_id: 'f1' })],
})
const { w } = await mountView('/folders/f1')
expect(w.text()).toContain('SubFolder')
})
it('renders document rows', async () => {
mockListDocuments.mockResolvedValue({
items: [makeDoc({ id: 'd1', original_name: 'report.pdf' })],
total: 1,
})
const { w } = await mountView('/folders/f1')
expect(w.text()).toContain('report.pdf')
})
})
describe('FileManagerView — folder row click navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
})
async function setupWithFolders(folders) {
mockListFolders.mockResolvedValue({ items: folders })
const { w, router } = await mountView('/')
return { w, router }
}
it('clicking folder ICON cell navigates to /folders/:id', async () => {
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// Column 1 (icon cell) — the amber div
const iconCell = w.find('.bg-amber-50.rounded-lg')
await iconCell.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/target-1')
})
it('clicking folder NAME text navigates to /folders/:id', async () => {
// This tests the bug: @click.stop on the name wrapper div prevents navigation
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// Column 2 (name cell) — the span with the folder name
const nameSpan = w.findAll('span').find(s => s.text() === 'Clickme')
expect(nameSpan).toBeTruthy()
await nameSpan.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/target-1')
})
it('clicking folder DATE cell navigates to /folders/:id', async () => {
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// find the date cells (text-gray-400 text-xs spans)
const dateCells = w.findAll('.text-right.text-xs.text-gray-400')
// Click the first date cell (the "—" size or actual date)
if (dateCells.length > 0) {
await dateCells[0].trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/target-1')
}
})
it('clicking rename button does NOT navigate', async () => {
const { w, router } = await setupWithFolders([makeFolder({ id: 'target-1', name: 'Clickme' })])
// Find rename button (pencil icon button in hover actions)
const actionBtns = w.findAll('.opacity-0.group-hover\\:opacity-100 button')
if (actionBtns.length > 0) {
await actionBtns[0].trigger('click')
await flushPromises()
// Should still be at root
expect(router.currentRoute.value.path).toBe('/')
}
})
})
describe('FileManagerView — subfolder creation does not break navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({
id: 'parent-1', name: 'Test',
breadcrumb: [{ id: 'parent-1', name: 'Test' }],
})
})
it('after creating subfolder, clicking its name navigates correctly', async () => {
const initialChildren = [makeFolder({ id: 'existing-1', name: 'Existing', parent_id: 'parent-1' })]
mockListFolders.mockResolvedValue({ items: initialChildren })
const newSubfolder = makeFolder({ id: 'new-sub', name: 'NewSub', parent_id: 'parent-1' })
mockCreateFolder.mockResolvedValue(newSubfolder)
const { w, router } = await mountView('/folders/parent-1')
// Simulate creating a new subfolder (as if user typed in the new-folder input)
const { useFoldersStore } = await import('../../stores/folders.js')
const foldersStore = useFoldersStore()
await foldersStore.createFolder('NewSub', 'parent-1')
await flushPromises()
// Now "NewSub" should appear in the list
expect(w.text()).toContain('NewSub')
// Clicking the name of "NewSub" should navigate — this is the regression test
const nameSpan = w.findAll('span').find(s => s.text() === 'NewSub')
expect(nameSpan).toBeTruthy()
await nameSpan.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/new-sub')
})
it('after creating subfolder, clicking existing folder name still navigates', async () => {
const initial = [makeFolder({ id: 'existing-1', name: 'Existing', parent_id: 'parent-1' })]
mockListFolders.mockResolvedValue({ items: initial })
mockCreateFolder.mockResolvedValue(
makeFolder({ id: 'new-sub', name: 'NewSub', parent_id: 'parent-1' })
)
const { w, router } = await mountView('/folders/parent-1')
const { useFoldersStore } = await import('../../stores/folders.js')
await useFoldersStore().createFolder('NewSub', 'parent-1')
await flushPromises()
const nameSpan = w.findAll('span').find(s => s.text() === 'Existing')
expect(nameSpan).toBeTruthy()
await nameSpan.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/folders/existing-1')
})
})
describe('FileManagerView — drag-and-drop document onto folder', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMoveDocument.mockResolvedValue({})
mockListDocuments.mockResolvedValue({
items: [makeDoc({ id: 'd1', original_name: 'doc.pdf' })],
total: 1,
})
mockListFolders.mockResolvedValue({
items: [makeFolder({ id: 'f-target', name: 'Target' })],
})
mockGetFolder.mockResolvedValue({
id: 'parent-1', name: 'Parent',
breadcrumb: [{ id: 'parent-1', name: 'Parent' }],
})
})
it('dragstart on document row sets dragging state', async () => {
const { w } = await mountView('/folders/parent-1')
const docRow = w.findAll('[draggable="true"]')[0]
expect(docRow).toBeTruthy()
await docRow.trigger('dragstart', { dataTransfer: { effectAllowed: '', setData: vi.fn(), types: [] } })
// The row gets opacity-50 when dragging
await w.vm.$nextTick()
})
it('drop on folder row calls moveToFolder', async () => {
const { w } = await mountView('/folders/parent-1')
await flushPromises()
// Start dragging doc
const docRow = w.findAll('[draggable="true"]')[0]
const dataTransfer = { effectAllowed: '', setData: vi.fn(), types: [] }
await docRow.trigger('dragstart', { dataTransfer })
await w.vm.$nextTick()
// Find folder row and drop on it
const folderRows = w.findAll('.grid.grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]').filter(
el => el.text().includes('Target')
)
if (folderRows.length > 0) {
await folderRows[0].trigger('drop', { dataTransfer: { types: [] } })
await flushPromises()
expect(mockMoveDocument).toHaveBeenCalled()
}
})
})
describe('FileManagerView — empty states', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Empty', breadcrumb: [] })
})
it('shows "No folders yet" when root has no folders', async () => {
const { w } = await mountView('/')
expect(w.text()).toContain('No folders yet')
})
it('shows "This folder is empty" inside an empty subfolder', async () => {
const { w } = await mountView('/folders/f1')
expect(w.text()).toContain('This folder is empty')
})
})
describe('FileManagerView — new folder creation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListFolders.mockResolvedValue({ items: [] })
mockListDocuments.mockResolvedValue({ items: [], total: 0 })
mockGetFolder.mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] })
mockCreateFolder.mockResolvedValue(makeFolder({ id: 'new-1', name: 'MyNewFolder' }))
})
it('clicking "New folder" button shows inline input', async () => {
const { w } = await mountView('/')
const newBtn = w.find('button.flex.items-center.gap-1\\.5')
await newBtn.trigger('click')
await w.vm.$nextTick()
expect(w.find('input[placeholder="Folder name"]').exists()).toBe(true)
})
it('pressing Enter in folder name input creates folder at current level', async () => {
const { w } = await mountView('/folders/f1')
// Open new folder input
await w.find('button.flex.items-center.gap-1\\.5').trigger('click')
await w.vm.$nextTick()
// Type folder name and submit
const input = w.find('input[placeholder="Folder name"]')
await input.setValue('MyNewFolder')
await input.trigger('keydown.enter')
await flushPromises()
expect(mockCreateFolder).toHaveBeenCalledWith('MyNewFolder', 'f1')
})
it('pressing Escape cancels folder creation', async () => {
const { w } = await mountView('/')
await w.find('button.flex.items-center.gap-1\\.5').trigger('click')
await w.vm.$nextTick()
await w.find('input[placeholder="Folder name"]').trigger('keydown.escape')
await w.vm.$nextTick()
expect(w.find('input[placeholder="Folder name"]').exists()).toBe(false)
})
})
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
},
})