-
-
Folders
+
+
+
+
+ Folders
+
-
-
+
+
{{ newFolderError }}
-
-
Loading…
-
+ Loading…
+ No folders yet
+
-
- {{ folder.name }}
-
+ :folder="folder"
+ :depth="1"
+ />
@@ -118,7 +113,7 @@
-
+
@@ -172,27 +167,34 @@
diff --git a/frontend/src/views/__tests__/FileManagerView.test.js b/frontend/src/views/__tests__/FileManagerView.test.js
new file mode 100644
index 0000000..487a9f2
--- /dev/null
+++ b/frontend/src/views/__tests__/FileManagerView.test.js
@@ -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: '
', props: ['segments'], emits: ['navigate'] },
+}))
+vi.mock('../../components/upload/DropZone.vue', () => ({
+ default: { template: '
', emits: ['files-selected'] },
+}))
+vi.mock('../../components/upload/UploadProgress.vue', () => ({
+ default: { template: '
', props: ['items'] },
+}))
+vi.mock('../../components/folders/FolderDeleteModal.vue', () => ({
+ default: { template: '
', props: ['folder'], emits: ['confirm', 'cancel'] },
+}))
+vi.mock('../../components/sharing/ShareModal.vue', () => ({
+ default: { template: '
', props: ['doc'], emits: ['close'] },
+}))
+vi.mock('../../components/documents/SearchBar.vue', () => ({
+ default: { template: '
', props: ['modelValue'] },
+}))
+vi.mock('../../components/documents/SortControls.vue', () => ({
+ default: { template: '
', props: ['sort', 'order'], emits: ['change'] },
+}))
+vi.mock('../../components/topics/TopicBadge.vue', () => ({
+ default: { template: '
', 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: '
' } },
+ ],
+ })
+}
+
+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)
+ })
+})
diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js
new file mode 100644
index 0000000..61ea681
--- /dev/null
+++ b/frontend/vitest.config.js
@@ -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,
+ },
+})