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
@@ -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 }
})