feat(10-07): AppSidebar UX-03 skeletons, UX-01 EmptyState micro, UX-14 remove New button
- UX-14: remove startNewFolder/cancelNewFolder/submitNewFolder + all related state (showNewFolderInput, newFolderName, newFolderError) from script and template - UX-14: remove <button @click="startNewFolder">New</button> from folder section header - UX-14: remove inline new-folder <div v-if="showNewFolderInput"> input block - UX-03: replace Loading… divs with animate-pulse skeleton rows (3-row pattern) in all three sections (folders, cloud, topics) - UX-01 sidebar micro: wire EmptyState size=sm for each empty section - folders: icon=folder headline="Create a folder in the file manager" - cloud: icon=cloud headline="Connect in Settings" with #cta Settings link - topics: icon=tag headline="No topics yet" - Add EmptyState import; StorageBrowser.startNewFolder untouched (invariant) - Update tests to work with script setup (Composition API) reality; all 9 GREEN
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
|
||||
<!-- Logo -->
|
||||
<div class="px-6 py-5 border-b border-gray-100">
|
||||
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocuVault</h1>
|
||||
<p class="text-xs text-gray-400 mt-0.5">Document Manager</p>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 px-3 py-4 overflow-y-auto">
|
||||
<router-link
|
||||
to="/topics"
|
||||
@@ -20,7 +18,6 @@
|
||||
All Topics
|
||||
</router-link>
|
||||
|
||||
<!-- Shared with me -->
|
||||
<router-link
|
||||
to="/shared"
|
||||
class="nav-link"
|
||||
@@ -41,10 +38,8 @@
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Folders root + collapsible tree -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<!-- Expand/collapse chevron -->
|
||||
<button
|
||||
@click="foldersExpanded = !foldersExpanded"
|
||||
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0"
|
||||
@@ -59,7 +54,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- "Folders" navigates to root file manager -->
|
||||
<router-link
|
||||
to="/"
|
||||
class="nav-link flex-1 min-w-0"
|
||||
@@ -71,36 +65,22 @@
|
||||
</svg>
|
||||
Folders
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
@click="startNewFolder"
|
||||
class="text-xs text-indigo-600 hover:underline shrink-0 mr-1"
|
||||
title="New root folder"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible content -->
|
||||
<template v-if="foldersExpanded">
|
||||
<!-- Inline new root folder input -->
|
||||
<div v-if="showNewFolderInput" class="px-3 mb-2 mt-1">
|
||||
<input
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
placeholder="Folder name"
|
||||
class="block w-full border border-gray-300 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
@keydown.enter="submitNewFolder"
|
||||
@keydown.escape="cancelNewFolder"
|
||||
autofocus
|
||||
/>
|
||||
<p v-if="newFolderError" class="text-red-500 text-xs mt-1">{{ newFolderError }}</p>
|
||||
<div v-if="loadingRoots" class="pl-7 py-1 space-y-1">
|
||||
<div v-for="n in 3" :key="`sk-f-${n}`" class="flex items-center gap-2 py-1">
|
||||
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-folders tree -->
|
||||
<div v-if="loadingRoots" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
|
||||
<div v-else-if="foldersStore.rootFolders.length === 0 && !showNewFolderInput"
|
||||
class="pl-7 py-1 text-xs text-gray-400">No folders yet</div>
|
||||
<EmptyState
|
||||
v-else-if="foldersStore.rootFolders.length === 0"
|
||||
size="sm"
|
||||
icon="folder"
|
||||
headline="Create a folder in the file manager"
|
||||
class="pl-7"
|
||||
/>
|
||||
<FolderTreeItem
|
||||
v-for="folder in foldersStore.rootFolders"
|
||||
:key="folder.id"
|
||||
@@ -110,10 +90,8 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Storage section -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<!-- Expand/collapse chevron -->
|
||||
<button
|
||||
@click="cloudExpanded = !cloudExpanded"
|
||||
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0"
|
||||
@@ -128,7 +106,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- "Cloud Storage" navigates to the cloud overview -->
|
||||
<router-link
|
||||
to="/cloud"
|
||||
class="nav-link flex-1 min-w-0"
|
||||
@@ -142,12 +119,24 @@
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible content -->
|
||||
<template v-if="cloudExpanded">
|
||||
<div v-if="loadingCloudConnections" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
|
||||
<div v-else-if="activeCloudConnections.length === 0" class="pl-7 py-1 text-xs text-gray-400">
|
||||
No cloud storage connected
|
||||
<div v-if="loadingCloudConnections" class="pl-7 py-1 space-y-1">
|
||||
<div v-for="n in 3" :key="`sk-c-${n}`" class="flex items-center gap-2 py-1">
|
||||
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else-if="activeCloudConnections.length === 0"
|
||||
size="sm"
|
||||
icon="cloud"
|
||||
headline="Connect in Settings"
|
||||
class="pl-7"
|
||||
>
|
||||
<template #cta>
|
||||
<router-link to="/settings" class="ml-1 text-indigo-600 hover:underline">Settings</router-link>
|
||||
</template>
|
||||
</EmptyState>
|
||||
<CloudProviderTreeItem
|
||||
v-for="connection in activeCloudConnections"
|
||||
:key="connection.id"
|
||||
@@ -157,11 +146,21 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Topics list -->
|
||||
<div class="mt-3">
|
||||
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Topics</p>
|
||||
<div v-if="topicsStore.loading" class="px-3 py-1 text-xs text-gray-400">Loading…</div>
|
||||
<div v-else-if="topicsStore.topics.length === 0" class="px-3 py-1 text-xs text-gray-400">No topics yet</div>
|
||||
<div v-if="topicsStore.loading" class="px-3 py-1 space-y-1">
|
||||
<div v-for="n in 3" :key="`sk-t-${n}`" class="flex items-center gap-2 py-1">
|
||||
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else-if="topicsStore.topics.length === 0"
|
||||
size="sm"
|
||||
icon="tag"
|
||||
headline="No topics yet"
|
||||
class="px-3"
|
||||
/>
|
||||
<router-link
|
||||
v-for="topic in topicsStore.topics"
|
||||
:key="topic.id"
|
||||
@@ -179,12 +178,9 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Quota bar -->
|
||||
<QuotaBar />
|
||||
|
||||
<!-- Settings + Admin link -->
|
||||
<div class="px-3 py-4 border-t border-gray-100">
|
||||
<!-- Admin link (admin users only) -->
|
||||
<router-link
|
||||
v-if="authStore.user?.role === 'admin'"
|
||||
to="/admin"
|
||||
@@ -211,7 +207,6 @@
|
||||
Settings
|
||||
</router-link>
|
||||
|
||||
<!-- User identity footer -->
|
||||
<div v-if="authStore.user" class="flex items-center gap-3 px-4 py-3 border-t border-gray-100 mt-2 -mx-3">
|
||||
<div class="bg-indigo-100 text-indigo-700 text-xs font-semibold rounded-full w-8 h-8 flex items-center justify-center shrink-0">
|
||||
{{ authStore.user.email ? authStore.user.email[0].toUpperCase() : '?' }}
|
||||
@@ -242,6 +237,7 @@ import { useCloudConnectionsStore } from '../../stores/cloudConnections.js'
|
||||
import QuotaBar from './QuotaBar.vue'
|
||||
import FolderTreeItem from '../folders/FolderTreeItem.vue'
|
||||
import CloudProviderTreeItem from '../cloud/CloudProviderTreeItem.vue'
|
||||
import EmptyState from '../ui/EmptyState.vue'
|
||||
import * as api from '../../api/client.js'
|
||||
|
||||
const topicsStore = useTopicsStore()
|
||||
@@ -251,9 +247,6 @@ const cloudConnectionsStore = useCloudConnectionsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const sharedCount = ref(0)
|
||||
const showNewFolderInput = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderError = ref('')
|
||||
const loadingRoots = ref(true)
|
||||
const foldersExpanded = ref(false)
|
||||
const cloudExpanded = ref(true)
|
||||
@@ -285,32 +278,6 @@ async function signOut() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function startNewFolder() {
|
||||
newFolderName.value = ''
|
||||
newFolderError.value = ''
|
||||
showNewFolderInput.value = true
|
||||
}
|
||||
|
||||
function cancelNewFolder() {
|
||||
showNewFolderInput.value = false
|
||||
newFolderError.value = ''
|
||||
}
|
||||
|
||||
async function submitNewFolder() {
|
||||
const trimmed = newFolderName.value.trim()
|
||||
if (!trimmed) {
|
||||
newFolderError.value = 'Folder name cannot be empty.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await foldersStore.createFolder(trimmed, null)
|
||||
showNewFolderInput.value = false
|
||||
newFolderError.value = ''
|
||||
} catch (e) {
|
||||
newFolderError.value = e.message || 'Failed to create folder.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -33,21 +33,12 @@ function makeRouter() {
|
||||
})
|
||||
}
|
||||
|
||||
async function mountSidebar(storeOverrides = {}) {
|
||||
async function mountSidebarWithStubs(apiOverrides = {}) {
|
||||
const clientModule = await import('../../../api/client.js')
|
||||
Object.assign(clientModule, apiOverrides)
|
||||
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const { useFoldersStore } = await import('../../../stores/folders.js')
|
||||
const { useTopicsStore } = await import('../../../stores/topics.js')
|
||||
const { useCloudConnectionsStore } = await import('../../../stores/cloudConnections.js')
|
||||
|
||||
const foldersStore = useFoldersStore()
|
||||
const topicsStore = useTopicsStore()
|
||||
const cloudStore = useCloudConnectionsStore()
|
||||
|
||||
if (storeOverrides.folders !== undefined) Object.assign(foldersStore, storeOverrides.folders)
|
||||
if (storeOverrides.topics !== undefined) Object.assign(topicsStore, storeOverrides.topics)
|
||||
if (storeOverrides.cloud !== undefined) Object.assign(cloudStore, storeOverrides.cloud)
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
@@ -61,13 +52,6 @@ async function mountSidebar(storeOverrides = {}) {
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
if (storeOverrides.folders !== undefined) Object.assign(foldersStore, storeOverrides.folders)
|
||||
if (storeOverrides.topics !== undefined) Object.assign(topicsStore, storeOverrides.topics)
|
||||
if (storeOverrides.cloud !== undefined) Object.assign(cloudStore, storeOverrides.cloud)
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
@@ -77,69 +61,7 @@ describe('UX-03: AppSidebar shows skeleton placeholders while loading', () => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('renders skeleton rows in folder section when loadingRoots=true', async () => {
|
||||
const { listFolders } = await import('../../../api/client.js')
|
||||
let resolveListFolders
|
||||
listFolders.mockReturnValueOnce(new Promise(r => { resolveListFolders = r }))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: { ...STUBS, EmptyState: false } },
|
||||
})
|
||||
|
||||
const pulses = wrapper.findAll('.animate-pulse')
|
||||
expect(pulses.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
resolveListFolders({ items: [] })
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders skeleton rows in topics section when topicsStore.loading=true', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const { useTopicsStore } = await import('../../../stores/topics.js')
|
||||
const topicsStore = useTopicsStore()
|
||||
topicsStore.loading = true
|
||||
topicsStore.topics = []
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
topicsStore.loading = true
|
||||
topicsStore.topics = []
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const pulses = wrapper.findAll('.animate-pulse')
|
||||
expect(pulses.length).toBeGreaterThanOrEqual(3)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders skeleton rows in cloud section when loadingCloudConnections=true', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
@@ -172,6 +94,65 @@ describe('UX-03: AppSidebar shows skeleton placeholders while loading', () => {
|
||||
expect(pulses.length).toBeGreaterThanOrEqual(3)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders skeleton rows in topics section when topicsStore.loading=true', async () => {
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const { useTopicsStore } = await import('../../../stores/topics.js')
|
||||
const topicsStore = useTopicsStore()
|
||||
topicsStore.loading = true
|
||||
topicsStore.topics = []
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
|
||||
topicsStore.loading = true
|
||||
topicsStore.topics = []
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const pulses = wrapper.findAll('.animate-pulse')
|
||||
expect(pulses.length).toBeGreaterThanOrEqual(3)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('Loading… text is removed from all three sections', async () => {
|
||||
vi.doMock('../../../api/client.js', () => ({
|
||||
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.html()).not.toContain('Loading…')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UX-01 (sidebar micro): EmptyState size=sm appears when each section is empty', () => {
|
||||
@@ -199,16 +180,20 @@ describe('UX-01 (sidebar micro): EmptyState size=sm appears when each section is
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: { ...STUBS, EmptyState: false } },
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
foldersStore.rootFolders = []
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const emptyStates = wrapper.findAll('empty-state-stub,emptystate-stub,[data-testid="empty-state"]')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('icon="folder"')
|
||||
await wrapper.find('button[title="Expand folders"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const emptyStateStub = wrapper.find('empty-state-stub')
|
||||
expect(emptyStateStub.exists()).toBe(true)
|
||||
expect(emptyStateStub.attributes('icon')).toBe('folder')
|
||||
expect(emptyStateStub.attributes('size')).toBe('sm')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -231,7 +216,7 @@ describe('UX-01 (sidebar micro): EmptyState size=sm appears when each section is
|
||||
|
||||
const AppSidebar = (await import('../AppSidebar.vue')).default
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: { plugins: [router], stubs: { ...STUBS, EmptyState: false } },
|
||||
global: { plugins: [router], stubs: STUBS },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
@@ -239,8 +224,10 @@ describe('UX-01 (sidebar micro): EmptyState size=sm appears when each section is
|
||||
topicsStore.loading = false
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('icon="tag"')
|
||||
const emptyStates = wrapper.findAll('empty-state-stub')
|
||||
const tagEmpty = emptyStates.find(e => e.attributes('icon') === 'tag')
|
||||
expect(tagEmpty).toBeDefined()
|
||||
expect(tagEmpty.attributes('size')).toBe('sm')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -272,7 +259,7 @@ describe('UX-01 (sidebar micro): EmptyState size=sm appears when each section is
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('icon="cloud"')
|
||||
expect(html).toContain('Connect in Settings')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user