chore: merge executor worktree (10-06 StorageBrowser+FileManager wiring)
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
---
|
||||
phase: 10-ux-interaction
|
||||
plan: "06"
|
||||
subsystem: frontend/storage
|
||||
tags: [wave-1, skeleton, empty-state, breadcrumb, toast, ux, vitest, tdd]
|
||||
dependency_graph:
|
||||
requires: [10-02, 10-03, 10-04, 10-05]
|
||||
provides: [StorageBrowser-skeleton-UX-02, StorageBrowser-EmptyState-UX-01, BreadcrumbBar-wired-UX-12, toast-call-sites-UX-10]
|
||||
affects: [FileManagerView, CloudFolderView, StorageBrowser]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [TDD-red-green, skeleton-grid, EmptyState-discriminator, breadcrumb-label-mapping, toast-call-site]
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- frontend/src/components/storage/StorageBrowser.vue
|
||||
- frontend/src/views/FileManagerView.vue
|
||||
- frontend/src/views/CloudFolderView.vue
|
||||
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js
|
||||
- frontend/src/views/__tests__/FileManagerView.test.js
|
||||
deleted:
|
||||
- frontend/src/components/folders/FolderBreadcrumb.vue
|
||||
- frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js
|
||||
decisions:
|
||||
- "StorageBrowser uses breadcrumb.length > 0 as in-folder discriminator for EmptyState icon (folder vs document)"
|
||||
- "BreadcrumbBar receives :root-label based on mode prop ('Cloud' for cloud, 'Home' for local)"
|
||||
- "FolderBreadcrumb.vue deleted in same commit as BreadcrumbBar swap (no dead code per CLAUDE.md)"
|
||||
- "FileManagerView.test.js FolderBreadcrumb mock replaced with BreadcrumbBar mock (dead-code hygiene)"
|
||||
- "Toast wiring: useToastStore() called at call-site inside doMove/doDeleteDoc/onFilesSelected (no top-level const)"
|
||||
metrics:
|
||||
duration_minutes: 10
|
||||
completed_date: "2026-06-15T20:26:00Z"
|
||||
tasks_completed: 3
|
||||
tasks_total: 3
|
||||
files_created: 0
|
||||
files_modified: 5
|
||||
files_deleted: 2
|
||||
---
|
||||
|
||||
# Phase 10 Plan 06: StorageBrowser Wire-up Summary
|
||||
|
||||
**One-liner:** StorageBrowser replaced Loading text with 5 animated skeleton rows, inline empty divs with three EmptyState variants, and FolderBreadcrumb with BreadcrumbBar; FileManagerView and CloudFolderView mapped breadcrumb segments to `{id, label}` and wired toast call sites for move/delete/upload.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Promote UX-02 skeleton stubs to RED failing tests | 413d3f0 | `StorageBrowser.skeleton.test.js` |
|
||||
| 2 | Update StorageBrowser — skeleton, EmptyState, BreadcrumbBar swap | d040e77 | `StorageBrowser.vue` |
|
||||
| 3 | Update FileManagerView + CloudFolderView; delete FolderBreadcrumb | 9ea51d6 | `FileManagerView.vue`, `CloudFolderView.vue`, `FolderBreadcrumb.vue` (deleted), `FolderBreadcrumb.test.js` (deleted), `FileManagerView.test.js`, `StorageBrowser.skeleton.test.js` |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: RED tests for UX-02 skeleton
|
||||
|
||||
Promoted 4 `it.todo` stubs in `StorageBrowser.skeleton.test.js` to real assertions:
|
||||
- `renders 5 skeleton rows when loading=true and lists empty` — asserts `wrapper.findAll('.animate-pulse').length >= 5`
|
||||
- `Loading… text is absent when loading=true` — asserts `wrapper.text()` does not contain `'Loading…'`
|
||||
- `skeleton rows are NOT rendered when loading=false` — asserts zero `.animate-pulse` elements
|
||||
- `skeleton row grid matches grid-cols-[2rem_1fr_6rem_8rem_6rem]` — asserts at least one matching grid container
|
||||
|
||||
Tests 1 and 2 were RED before Task 2. All 4 turn GREEN after Task 2.
|
||||
|
||||
### Task 2: StorageBrowser.vue updated (GREEN)
|
||||
|
||||
**Imports:** `FolderBreadcrumb` replaced by `BreadcrumbBar` + `EmptyState` added.
|
||||
|
||||
**Template — BreadcrumbBar:** Replaced `<FolderBreadcrumb :segments="breadcrumb" ...>` with:
|
||||
```vue
|
||||
<BreadcrumbBar
|
||||
:segments="breadcrumb"
|
||||
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
|
||||
@navigate="$emit('breadcrumb-navigate', $event)"
|
||||
/>
|
||||
```
|
||||
|
||||
**Template — Skeleton rows (lines 226-238):** Replaced `<div v-if="loading">Loading…</div>` with `<template v-if="loading">` containing 5 skeleton row divs using `animate-pulse` and `grid-cols-[2rem_1fr_6rem_8rem_6rem]`.
|
||||
|
||||
**Template — EmptyState (lines 240-264):** Replaced 2 inline empty divs with 3 `<EmptyState>` blocks:
|
||||
- `v-else-if="searchQuery && ..."` with `icon="search"` and a CTA "Clear search" slot
|
||||
- `v-else-if="breadcrumb.length > 0 && ..."` with `icon="document"` (in-folder)
|
||||
- `v-else-if="..."` with `icon="folder"` (root)
|
||||
|
||||
### Task 3: FileManagerView + CloudFolderView wired; FolderBreadcrumb deleted
|
||||
|
||||
**FileManagerView.vue:**
|
||||
- `import { useToastStore }` added
|
||||
- `mappedBreadcrumb` computed added: `foldersStore.breadcrumb.map(f => ({ id: f.id, label: f.name }))`
|
||||
- Template binding changed to `:breadcrumb="mappedBreadcrumb"`
|
||||
- `doMove` updated: `toast.show('Document moved', 'success')` on success; `toast.show('Move failed: ...', 'error')` on catch
|
||||
- `doDeleteDoc` updated: `toast.show('Document deleted', 'success')` on success; `toast.show('Delete failed: ...', 'error')` on catch
|
||||
- `onFilesSelected` updated: summary toast after `Promise.allSettled` (success/warning/error based on succeeded count)
|
||||
- `console.error` calls removed from doMove and doDeleteDoc (toast communicates errors to user)
|
||||
|
||||
**CloudFolderView.vue:**
|
||||
- `mappedBreadcrumb` computed added: `breadcrumb.value.map(f => ({ id: f.id, label: f.name }))`
|
||||
- Template binding changed to `:breadcrumb="mappedBreadcrumb"`
|
||||
|
||||
**Deleted files:**
|
||||
- `frontend/src/components/folders/FolderBreadcrumb.vue` — replaced by BreadcrumbBar
|
||||
- `frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js` — deleted in same commit (no dead code)
|
||||
|
||||
**Test hygiene:**
|
||||
- `FileManagerView.test.js`: dead `FolderBreadcrumb` mock replaced with `BreadcrumbBar` mock
|
||||
- `StorageBrowser.skeleton.test.js`: unused `FolderBreadcrumb` stub entry removed from globalStubs
|
||||
|
||||
## Verification Results
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `StorageBrowser.skeleton` — 4 UX-02 tests GREEN | PASS |
|
||||
| `FileManagerView` — 20 tests | PASS |
|
||||
| `BreadcrumbBar` — regression | PASS |
|
||||
| `toast` — regression | PASS |
|
||||
| Full suite: 164 tests, 0 failures, 7 skipped (todo-only files) | PASS |
|
||||
| `grep -r "FolderBreadcrumb" frontend/src/` | 0 matches |
|
||||
| `<EmptyState>` count in StorageBrowser.vue | 3 |
|
||||
| `<BreadcrumbBar>` count in StorageBrowser.vue | 1 |
|
||||
| `animate-pulse` count in StorageBrowser.vue | 5 |
|
||||
| `Loading…` text in StorageBrowser.vue | 0 |
|
||||
| `useToastStore` in FileManagerView.vue | 4 occurrences |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug / Rule 2 - Dead Code] Replaced dead FolderBreadcrumb mock in FileManagerView.test.js**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** `vi.mock('../../components/folders/FolderBreadcrumb.vue', ...)` in FileManagerView.test.js was registering a mock for a deleted file, and the component was no longer imported anywhere in the codebase
|
||||
- **Fix:** Replaced with `vi.mock('../../components/ui/BreadcrumbBar.vue', ...)` — the component now used by StorageBrowser; removed FolderBreadcrumb stub from StorageBrowser.skeleton.test.js globalStubs
|
||||
- **Files modified:** `FileManagerView.test.js`, `StorageBrowser.skeleton.test.js`
|
||||
- **Commit:** 9ea51d6
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. All behaviors are fully wired.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. This plan modifies only frontend Vue components and test files. No new network endpoints, auth paths, file access patterns, or schema changes were introduced.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `frontend/src/components/storage/StorageBrowser.vue` — exists with BreadcrumbBar, EmptyState, skeleton
|
||||
- `frontend/src/views/FileManagerView.vue` — exists with mappedBreadcrumb, toast wiring
|
||||
- `frontend/src/views/CloudFolderView.vue` — exists with mappedBreadcrumb
|
||||
- `frontend/src/components/folders/FolderBreadcrumb.vue` — confirmed deleted
|
||||
- `frontend/src/components/folders/__tests__/FolderBreadcrumb.test.js` — confirmed deleted
|
||||
- Commit 413d3f0 — confirmed in git log (RED tests)
|
||||
- Commit d040e77 — confirmed in git log (StorageBrowser GREEN)
|
||||
- Commit 9ea51d6 — confirmed in git log (Task 3)
|
||||
- No unexpected file deletions (only FolderBreadcrumb files intentionally deleted)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,9 @@
|
||||
<!-- ── 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
|
||||
<BreadcrumbBar
|
||||
:segments="breadcrumb"
|
||||
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
|
||||
@navigate="$emit('breadcrumb-navigate', $event)"
|
||||
/>
|
||||
<div class="ml-auto flex items-center gap-2 shrink-0">
|
||||
@@ -223,25 +224,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="!loading && folders.length === 0 && files.length === 0 && !showNewFolderInput"
|
||||
class="px-4 py-10 text-center text-gray-300"
|
||||
>
|
||||
<p class="text-gray-400 text-sm">{{ emptyMessage }}</p>
|
||||
<p class="text-xs mt-1">{{ emptyHint }}</p>
|
||||
</div>
|
||||
<template v-if="loading">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="`sk-${n}`"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100"
|
||||
>
|
||||
<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>
|
||||
<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse hidden md:block"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse hidden sm:block"></div>
|
||||
<div class="w-14 h-3 bg-gray-100 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Search no-results -->
|
||||
<div
|
||||
v-else-if="searchQuery && files.length === 0 && folders.length === 0"
|
||||
class="px-4 py-10 text-center text-sm text-gray-400"
|
||||
<EmptyState
|
||||
v-else-if="searchQuery && folders.length === 0 && files.length === 0"
|
||||
icon="search"
|
||||
:headline="`No results for "${searchQuery}"`"
|
||||
subtext="Try a different search term or clear the filter."
|
||||
>
|
||||
No items match "{{ searchQuery }}".
|
||||
</div>
|
||||
<template #cta>
|
||||
<button @click="$emit('search-change', '')" class="mt-3 text-sm text-indigo-600 hover:underline">
|
||||
Clear search
|
||||
</button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="py-6 text-center text-sm text-gray-400">Loading…</div>
|
||||
<EmptyState
|
||||
v-else-if="breadcrumb.length > 0 && folders.length === 0 && files.length === 0 && !showNewFolderInput"
|
||||
icon="document"
|
||||
:headline="emptyMessage"
|
||||
:subtext="emptyHint"
|
||||
/>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="folders.length === 0 && files.length === 0 && !showNewFolderInput"
|
||||
icon="folder"
|
||||
:headline="emptyMessage"
|
||||
:subtext="emptyHint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,7 +271,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import FolderBreadcrumb from '../folders/FolderBreadcrumb.vue'
|
||||
import BreadcrumbBar from '../ui/BreadcrumbBar.vue'
|
||||
import EmptyState from '../ui/EmptyState.vue'
|
||||
import SearchBar from '../documents/SearchBar.vue'
|
||||
import SortControls from '../documents/SortControls.vue'
|
||||
import DropZone from '../upload/DropZone.vue'
|
||||
|
||||
@@ -1,10 +1,58 @@
|
||||
import { describe, it } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import StorageBrowser from '../StorageBrowser.vue'
|
||||
|
||||
const globalStubs = {
|
||||
BreadcrumbBar: true,
|
||||
SearchBar: true,
|
||||
SortControls: true,
|
||||
DropZone: true,
|
||||
UploadProgress: true,
|
||||
TopicBadge: true,
|
||||
AppIcon: true,
|
||||
EmptyState: true,
|
||||
}
|
||||
|
||||
describe('UX-02: StorageBrowser shows skeleton rows during loading', () => {
|
||||
it.todo('renders 5+ skeleton rows when loading=true (replaces Loading… text)')
|
||||
it.todo('skeleton rows use grid-cols-[2rem_1fr_6rem_8rem_6rem] matching real row grid')
|
||||
it.todo('skeleton rows use animate-pulse')
|
||||
it.todo('Loading… text is absent when loading=true (skeleton replaces it)')
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('renders 5 skeleton rows when loading=true and lists empty', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { loading: true, folders: [], files: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const pulseEls = wrapper.findAll('.animate-pulse')
|
||||
expect(pulseEls.length).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('Loading… text is absent when loading=true', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { loading: true, folders: [], files: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Loading…')
|
||||
})
|
||||
|
||||
it('skeleton rows are NOT rendered when loading=false', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { loading: false, folders: [], files: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const pulseEls = wrapper.findAll('.animate-pulse')
|
||||
expect(pulseEls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('skeleton row grid matches grid-cols-[2rem_1fr_6rem_8rem_6rem]', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { loading: true, folders: [], files: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const skeletonGrids = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]')
|
||||
expect(skeletonGrids.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
mode="cloud"
|
||||
:folders="folders"
|
||||
:files="files"
|
||||
:breadcrumb="breadcrumb"
|
||||
:breadcrumb="mappedBreadcrumb"
|
||||
:upload-queue="uploadQueue"
|
||||
:loading="loading"
|
||||
: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() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
mode="local"
|
||||
:folders="foldersStore.folders"
|
||||
:files="docsStore.documents"
|
||||
:breadcrumb="foldersStore.breadcrumb"
|
||||
:breadcrumb="mappedBreadcrumb"
|
||||
:upload-queue="uploadQueue"
|
||||
:loading="docsStore.loading || foldersStore.loading"
|
||||
:search-query="docsStore.searchQuery"
|
||||
@@ -49,6 +49,7 @@ 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 { useToastStore } from '../stores/toast.js'
|
||||
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
||||
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
|
||||
import ShareModal from '../components/sharing/ShareModal.vue'
|
||||
@@ -63,6 +64,9 @@ const browserRef = ref(null)
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
|
||||
const currentFolderId = computed(() => route.params.folderId ?? null)
|
||||
const mappedBreadcrumb = computed(() =>
|
||||
(foldersStore.breadcrumb || []).map(f => ({ id: f.id, label: f.name }))
|
||||
)
|
||||
|
||||
async function loadFolder(folderId) {
|
||||
if (folderId === null) {
|
||||
@@ -100,6 +104,7 @@ const uploadQueue = ref([])
|
||||
|
||||
async function onFilesSelected({ files, autoClassify }) {
|
||||
const folderId = currentFolderId.value
|
||||
const toast = useToastStore()
|
||||
const promises = files.map(file => {
|
||||
const item = reactive({ name: file.name, done: false, error: null, quotaError: null, topics: null })
|
||||
uploadQueue.value.unshift(item)
|
||||
@@ -112,6 +117,14 @@ async function onFilesSelected({ files, autoClassify }) {
|
||||
})
|
||||
await Promise.allSettled(promises)
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
@@ -143,11 +156,23 @@ async function confirmDeleteFolder() {
|
||||
const shareDoc = ref(null)
|
||||
|
||||
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) {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,8 +29,8 @@ vi.mock('../../api/client.js', () => ({
|
||||
}))
|
||||
|
||||
// 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/ui/BreadcrumbBar.vue', () => ({
|
||||
default: { template: '<nav><slot/></nav>', props: ['segments', 'rootLabel', 'showRoot'], emits: ['navigate'] },
|
||||
}))
|
||||
vi.mock('../../components/upload/DropZone.vue', () => ({
|
||||
default: { template: '<div class="dropzone"/>', emits: ['files-selected'] },
|
||||
|
||||
Reference in New Issue
Block a user