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