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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user