feat(10-06): wire breadcrumb mapping + toast call sites; delete FolderBreadcrumb
- FileManagerView: add mappedBreadcrumb computed ({id,label} from foldersStore.breadcrumb)
- FileManagerView: import useToastStore; add toast.show() to doMove, doDeleteDoc, onFilesSelected
- FileManagerView: remove console.error from doMove/doDeleteDoc (toast communicates errors)
- CloudFolderView: add mappedBreadcrumb computed; pass to StorageBrowser :breadcrumb
- Delete FolderBreadcrumb.vue + FolderBreadcrumb.test.js (no dead code per CLAUDE.md)
- FileManagerView.test.js: replace dead FolderBreadcrumb mock with BreadcrumbBar mock
- StorageBrowser.skeleton.test.js: remove unused FolderBreadcrumb stub
- Full suite: 164/164 pass, 0 failures
This commit is contained in:
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<nav aria-label="Folder navigation">
|
|
||||||
<ol class="flex items-center gap-1 text-sm flex-wrap">
|
|
||||||
<!-- Root "Home" segment -->
|
|
||||||
<li class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
@click="emit('navigate', null)"
|
|
||||||
class="text-indigo-600 hover:underline font-medium"
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<template v-for="(segment, idx) in visibleSegments" :key="segment.id ?? 'ellipsis-' + idx">
|
|
||||||
<!-- Separator -->
|
|
||||||
<li class="shrink-0" aria-hidden="true">
|
|
||||||
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- Ellipsis (non-navigable) -->
|
|
||||||
<li v-if="segment.id === 'ellipsis'" class="flex items-center">
|
|
||||||
<span class="px-2 py-1 text-gray-400">…</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- Last segment (current folder, non-clickable) -->
|
|
||||||
<li v-else-if="idx === visibleSegments.length - 1" class="flex items-center">
|
|
||||||
<span class="text-gray-900 font-medium">{{ segment.name }}</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- Clickable segment -->
|
|
||||||
<li v-else class="flex items-center">
|
|
||||||
<button
|
|
||||||
@click="emit('navigate', segment.id)"
|
|
||||||
class="text-indigo-600 hover:underline font-medium"
|
|
||||||
>
|
|
||||||
{{ segment.name }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
segments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['navigate'])
|
|
||||||
|
|
||||||
const visibleSegments = computed(() => {
|
|
||||||
if (props.segments.length > 4) {
|
|
||||||
return [
|
|
||||||
props.segments[0],
|
|
||||||
{ id: 'ellipsis', name: '…' },
|
|
||||||
...props.segments.slice(-2),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return props.segments
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -5,7 +5,6 @@ import StorageBrowser from '../StorageBrowser.vue'
|
|||||||
|
|
||||||
const globalStubs = {
|
const globalStubs = {
|
||||||
BreadcrumbBar: true,
|
BreadcrumbBar: true,
|
||||||
FolderBreadcrumb: true,
|
|
||||||
SearchBar: true,
|
SearchBar: true,
|
||||||
SortControls: true,
|
SortControls: true,
|
||||||
DropZone: true,
|
DropZone: true,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
mode="cloud"
|
mode="cloud"
|
||||||
:folders="folders"
|
:folders="folders"
|
||||||
:files="files"
|
:files="files"
|
||||||
:breadcrumb="breadcrumb"
|
:breadcrumb="mappedBreadcrumb"
|
||||||
:upload-queue="uploadQueue"
|
:upload-queue="uploadQueue"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:empty-message="error || 'This folder is empty'"
|
:empty-message="error || 'This folder is empty'"
|
||||||
@@ -45,6 +45,10 @@ const breadcrumb = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mappedBreadcrumb = computed(() =>
|
||||||
|
breadcrumb.value.map(f => ({ id: f.id, label: f.name }))
|
||||||
|
)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
mode="local"
|
mode="local"
|
||||||
:folders="foldersStore.folders"
|
:folders="foldersStore.folders"
|
||||||
:files="docsStore.documents"
|
:files="docsStore.documents"
|
||||||
:breadcrumb="foldersStore.breadcrumb"
|
:breadcrumb="mappedBreadcrumb"
|
||||||
:upload-queue="uploadQueue"
|
:upload-queue="uploadQueue"
|
||||||
:loading="docsStore.loading || foldersStore.loading"
|
:loading="docsStore.loading || foldersStore.loading"
|
||||||
:search-query="docsStore.searchQuery"
|
:search-query="docsStore.searchQuery"
|
||||||
@@ -49,6 +49,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useFoldersStore } from '../stores/folders.js'
|
import { useFoldersStore } from '../stores/folders.js'
|
||||||
import { useDocumentsStore } from '../stores/documents.js'
|
import { useDocumentsStore } from '../stores/documents.js'
|
||||||
import { useTopicsStore } from '../stores/topics.js'
|
import { useTopicsStore } from '../stores/topics.js'
|
||||||
|
import { useToastStore } from '../stores/toast.js'
|
||||||
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
||||||
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
|
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
|
||||||
import ShareModal from '../components/sharing/ShareModal.vue'
|
import ShareModal from '../components/sharing/ShareModal.vue'
|
||||||
@@ -63,6 +64,9 @@ const browserRef = ref(null)
|
|||||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const currentFolderId = computed(() => route.params.folderId ?? null)
|
const currentFolderId = computed(() => route.params.folderId ?? null)
|
||||||
|
const mappedBreadcrumb = computed(() =>
|
||||||
|
(foldersStore.breadcrumb || []).map(f => ({ id: f.id, label: f.name }))
|
||||||
|
)
|
||||||
|
|
||||||
async function loadFolder(folderId) {
|
async function loadFolder(folderId) {
|
||||||
if (folderId === null) {
|
if (folderId === null) {
|
||||||
@@ -100,6 +104,7 @@ const uploadQueue = ref([])
|
|||||||
|
|
||||||
async function onFilesSelected({ files, autoClassify }) {
|
async function onFilesSelected({ files, autoClassify }) {
|
||||||
const folderId = currentFolderId.value
|
const folderId = currentFolderId.value
|
||||||
|
const toast = useToastStore()
|
||||||
const promises = files.map(file => {
|
const promises = files.map(file => {
|
||||||
const item = reactive({ name: file.name, done: false, error: null, quotaError: null, topics: null })
|
const item = reactive({ name: file.name, done: false, error: null, quotaError: null, topics: null })
|
||||||
uploadQueue.value.unshift(item)
|
uploadQueue.value.unshift(item)
|
||||||
@@ -112,6 +117,14 @@ async function onFilesSelected({ files, autoClassify }) {
|
|||||||
})
|
})
|
||||||
await Promise.allSettled(promises)
|
await Promise.allSettled(promises)
|
||||||
await topicsStore.fetchTopics()
|
await topicsStore.fetchTopics()
|
||||||
|
const succeeded = uploadQueue.value.slice(0, files.length).filter(i => i.done).length
|
||||||
|
if (succeeded === files.length) {
|
||||||
|
toast.show(`${succeeded} file(s) uploaded`, 'success')
|
||||||
|
} else if (succeeded > 0) {
|
||||||
|
toast.show(`${succeeded} of ${files.length} file(s) uploaded`, 'warning')
|
||||||
|
} else {
|
||||||
|
toast.show('Upload failed', 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Folder CRUD ───────────────────────────────────────────────────────────────
|
// ── Folder CRUD ───────────────────────────────────────────────────────────────
|
||||||
@@ -143,11 +156,23 @@ async function confirmDeleteFolder() {
|
|||||||
const shareDoc = ref(null)
|
const shareDoc = ref(null)
|
||||||
|
|
||||||
async function doMove(docId, folderId) {
|
async function doMove(docId, folderId) {
|
||||||
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
|
const toast = useToastStore()
|
||||||
|
try {
|
||||||
|
await docsStore.moveToFolder(docId, folderId)
|
||||||
|
toast.show('Document moved', 'success')
|
||||||
|
} catch (e) {
|
||||||
|
toast.show('Move failed: ' + (e.message || 'unknown error'), 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doDeleteDoc(docId) {
|
async function doDeleteDoc(docId) {
|
||||||
try { await docsStore.remove(docId) } catch (e) { console.error(e.message) }
|
const toast = useToastStore()
|
||||||
|
try {
|
||||||
|
await docsStore.remove(docId)
|
||||||
|
toast.show('Document deleted', 'success')
|
||||||
|
} catch (e) {
|
||||||
|
toast.show('Delete failed: ' + (e.message || 'unknown error'), 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Topic color lookup ────────────────────────────────────────────────────────
|
// ── Topic color lookup ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ vi.mock('../../api/client.js', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Stub heavy child components so we only test FileManagerView logic
|
// Stub heavy child components so we only test FileManagerView logic
|
||||||
vi.mock('../../components/folders/FolderBreadcrumb.vue', () => ({
|
vi.mock('../../components/ui/BreadcrumbBar.vue', () => ({
|
||||||
default: { template: '<nav><slot/></nav>', props: ['segments'], emits: ['navigate'] },
|
default: { template: '<nav><slot/></nav>', props: ['segments', 'rootLabel', 'showRoot'], emits: ['navigate'] },
|
||||||
}))
|
}))
|
||||||
vi.mock('../../components/upload/DropZone.vue', () => ({
|
vi.mock('../../components/upload/DropZone.vue', () => ({
|
||||||
default: { template: '<div class="dropzone"/>', emits: ['files-selected'] },
|
default: { template: '<div class="dropzone"/>', emits: ['files-selected'] },
|
||||||
|
|||||||
Reference in New Issue
Block a user