Merge branch 'feat/sidebar-explorer' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a9d0e49159
2 files changed
+261
-105
No files matched your search
@@ -911,7 +911,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rootDocs = useMemo(
|
const rootDocs = useMemo(
|
||||||
() => docs.filter((d) => !d.folderPath),
|
() => docs.filter((d) => !d.folderPath && !d.isPinned),
|
||||||
[docs]
|
[docs]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1330,114 +1330,105 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Navigation Tree */}
|
{/* Scrollable Navigation Tree */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||||
{/* Pinned Notes */}
|
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
||||||
{pinnedDocs.length > 0 && (
|
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
{/* Pinned Notes */}
|
||||||
<Pin className="w-3 h-3 text-status-warn" />
|
{pinnedDocs.length > 0 && (
|
||||||
<span>Angeheftet</span>
|
<div
|
||||||
</div>
|
className={`space-y-0.5 ${
|
||||||
<div className="space-y-0.5">
|
folderTree.length > 0 || rootDocs.length > 0
|
||||||
{pinnedDocs.map((d) => (
|
? 'border-b border-border pb-1.5 mb-1.5'
|
||||||
<div
|
: ''
|
||||||
key={`pin-${d.id}`}
|
}`}
|
||||||
draggable
|
>
|
||||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
{pinnedDocs.map((d) => (
|
||||||
onClick={() => {
|
<div
|
||||||
setSelectedDocId(d.id);
|
key={`pin-${d.id}`}
|
||||||
setSubView('editor');
|
draggable
|
||||||
setMobileShowDetail(true);
|
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||||
}}
|
onClick={() => {
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
setSelectedDocId(d.id);
|
||||||
selectedDocId === d.id && subView === 'editor'
|
setSubView('editor');
|
||||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
setMobileShowDetail(true);
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
}}
|
||||||
}`}
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||||
>
|
selectedDocId === d.id && subView === 'editor'
|
||||||
<span>{d.icon || '📝'}</span>
|
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
</div>
|
}`}
|
||||||
))}
|
>
|
||||||
</div>
|
<span className="shrink-0">{d.icon || '📝'}</span>
|
||||||
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
|
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||||
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
|
)}
|
||||||
|
<Pin className="w-3 h-3 text-muted shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Folders and Root Documents */}
|
||||||
|
{(folderTree.length > 0 || rootDocs.length > 0) && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{folderTree.map((node) => (
|
||||||
|
<FolderTreeItem
|
||||||
|
key={node.fullPath}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
setExpandedFolders={setExpandedFolders}
|
||||||
|
selectedDocId={selectedDocId}
|
||||||
|
subView={subView}
|
||||||
|
dragOverFolder={dragOverFolder}
|
||||||
|
setDragOverFolder={setDragOverFolder}
|
||||||
|
handleDropOnFolder={handleDropOnFolder}
|
||||||
|
handleFolderDragStart={handleFolderDragStart}
|
||||||
|
handleDocDragStart={handleDocDragStart}
|
||||||
|
handleCreateDocument={handleCreateDocument}
|
||||||
|
handleOpenNewFolder={handleOpenNewFolder}
|
||||||
|
handleShareFolderClick={handleShareFolderClick}
|
||||||
|
handleDeleteFolder={handleDeleteFolder}
|
||||||
|
onSelectDoc={(id) => {
|
||||||
|
setSelectedDocId(id);
|
||||||
|
setTargetBlockId(null);
|
||||||
|
setNewlyCreatedDocId(null);
|
||||||
|
setSubView('editor');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{rootDocs.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDocId(d.id);
|
||||||
|
setSubView('editor');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||||
|
selectedDocId === d.id && subView === 'editor'
|
||||||
|
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||||
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{d.icon || '📝'}</span>
|
||||||
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
|
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||||
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Folders Tree with Recursive Hierarchical Nesting */}
|
|
||||||
{folderTree.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
|
||||||
Ordner
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{folderTree.map((node) => (
|
|
||||||
<FolderTreeItem
|
|
||||||
key={node.fullPath}
|
|
||||||
node={node}
|
|
||||||
depth={0}
|
|
||||||
expandedFolders={expandedFolders}
|
|
||||||
setExpandedFolders={setExpandedFolders}
|
|
||||||
selectedDocId={selectedDocId}
|
|
||||||
subView={subView}
|
|
||||||
dragOverFolder={dragOverFolder}
|
|
||||||
setDragOverFolder={setDragOverFolder}
|
|
||||||
handleDropOnFolder={handleDropOnFolder}
|
|
||||||
handleFolderDragStart={handleFolderDragStart}
|
|
||||||
handleDocDragStart={handleDocDragStart}
|
|
||||||
handleCreateDocument={handleCreateDocument}
|
|
||||||
handleOpenNewFolder={handleOpenNewFolder}
|
|
||||||
handleShareFolderClick={handleShareFolderClick}
|
|
||||||
handleDeleteFolder={handleDeleteFolder}
|
|
||||||
onSelectDoc={(id) => {
|
|
||||||
setSelectedDocId(id);
|
|
||||||
setTargetBlockId(null);
|
|
||||||
setNewlyCreatedDocId(null);
|
|
||||||
setSubView('editor');
|
|
||||||
setMobileShowDetail(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Root Documents */}
|
|
||||||
<div>
|
|
||||||
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
|
||||||
Notizen (Hauptebene)
|
|
||||||
</div>
|
|
||||||
{rootDocs.length === 0 ? (
|
|
||||||
<div className="px-2 py-2 text-xs text-faint italic">
|
|
||||||
Keine Notizen auf der Hauptebene.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{rootDocs.map((d) => (
|
|
||||||
<div
|
|
||||||
key={d.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDocId(d.id);
|
|
||||||
setSubView('editor');
|
|
||||||
setMobileShowDetail(true);
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
|
||||||
selectedDocId === d.id && subView === 'editor'
|
|
||||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>{d.icon || '📝'}</span>
|
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
|
||||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
|
||||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom drop target for moving to root */}
|
{/* Bottom drop target for moving to root */}
|
||||||
{(draggingDocId || draggingFolderPath) && (
|
{(draggingDocId || draggingFolderPath) && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { WorkspaceView } from '../WorkspaceView';
|
||||||
|
import { api } from '../../../lib/api';
|
||||||
|
import { Document } from '../../../lib/types';
|
||||||
|
|
||||||
|
vi.mock('../DocumentEditor', () => ({
|
||||||
|
DocumentEditor: () => <div data-testid="document-editor">DocumentEditor</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/api', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../../lib/api')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
getDocuments: vi.fn(),
|
||||||
|
getFolders: vi.fn(),
|
||||||
|
getSharedFolders: vi.fn(),
|
||||||
|
getWorkspaceTags: vi.fn(),
|
||||||
|
getTrash: vi.fn(),
|
||||||
|
},
|
||||||
|
onWebSocketMessage: vi.fn(() => () => {}),
|
||||||
|
triggerHaptic: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeDoc = (overrides: Partial<Document> & { id: string; title: string }): Document => ({
|
||||||
|
id: overrides.id,
|
||||||
|
userId: 'u1',
|
||||||
|
title: overrides.title,
|
||||||
|
icon: '📝',
|
||||||
|
folderPath: '',
|
||||||
|
content: '',
|
||||||
|
blocks: [],
|
||||||
|
tags: [],
|
||||||
|
isPinned: false,
|
||||||
|
isArchived: false,
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-29T10:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WorkspaceView sidebar explorer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.mocked(api.getFolders).mockResolvedValue([]);
|
||||||
|
vi.mocked(api.getSharedFolders).mockResolvedValue([]);
|
||||||
|
vi.mocked(api.getWorkspaceTags).mockResolvedValue([]);
|
||||||
|
vi.mocked(api.getTrash).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state "Noch keine Notizen" when there are no notes and no folders', async () => {
|
||||||
|
vi.mocked(api.getDocuments).mockResolvedValue([]);
|
||||||
|
vi.mocked(api.getFolders).mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Noch keine Notizen')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('Angeheftet')).toBeNull();
|
||||||
|
expect(screen.queryByText('Ordner')).toBeNull();
|
||||||
|
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
|
||||||
|
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pinned docs, folders, and root docs without headings', async () => {
|
||||||
|
const docs: Document[] = [
|
||||||
|
makeDoc({
|
||||||
|
id: 'doc_pin_root',
|
||||||
|
title: 'Wichtige Pinned Notiz',
|
||||||
|
icon: '⭐',
|
||||||
|
isPinned: true,
|
||||||
|
}),
|
||||||
|
makeDoc({
|
||||||
|
id: 'doc_root',
|
||||||
|
title: 'Normale Root Notiz',
|
||||||
|
icon: '📝',
|
||||||
|
}),
|
||||||
|
makeDoc({
|
||||||
|
id: 'doc_in_folder',
|
||||||
|
title: 'Projekt Plan',
|
||||||
|
icon: '📄',
|
||||||
|
folderPath: 'Projekte',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(api.getDocuments).mockResolvedValue(docs);
|
||||||
|
vi.mocked(api.getFolders).mockResolvedValue(['Projekte']);
|
||||||
|
|
||||||
|
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Wichtige Pinned Notiz')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Normale Root Notiz')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Projekte')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure no old headings are rendered
|
||||||
|
expect(screen.queryByText('Angeheftet')).toBeNull();
|
||||||
|
expect(screen.queryByText('Ordner')).toBeNull();
|
||||||
|
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
|
||||||
|
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
|
||||||
|
|
||||||
|
// The pinned root doc appears only once
|
||||||
|
const pinnedMatches = screen.getAllByText('Wichtige Pinned Notiz');
|
||||||
|
expect(pinnedMatches.length).toBe(1);
|
||||||
|
|
||||||
|
// Pinned container has separator when items follow
|
||||||
|
const pinnedItem = screen.getByText('Wichtige Pinned Notiz').closest('div[draggable="true"]');
|
||||||
|
const pinnedContainer = pinnedItem?.parentElement;
|
||||||
|
expect(pinnedContainer?.className).toContain('border-b border-border pb-1.5 mb-1.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render separator under pinned notes when nothing follows', async () => {
|
||||||
|
const docs: Document[] = [
|
||||||
|
makeDoc({
|
||||||
|
id: 'doc_pin_only',
|
||||||
|
title: 'Einzige Notiz Pinned',
|
||||||
|
icon: '⭐',
|
||||||
|
isPinned: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(api.getDocuments).mockResolvedValue(docs);
|
||||||
|
vi.mocked(api.getFolders).mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Einzige Notiz Pinned')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinnedItem = screen.getByText('Einzige Notiz Pinned').closest('div[draggable="true"]');
|
||||||
|
const pinnedContainer = pinnedItem?.parentElement;
|
||||||
|
expect(pinnedContainer?.className).not.toContain('border-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pinned note in folder visible in its folder', async () => {
|
||||||
|
const docs: Document[] = [
|
||||||
|
makeDoc({
|
||||||
|
id: 'doc_pin_in_folder',
|
||||||
|
title: 'Pinned im Ordner',
|
||||||
|
icon: '📌',
|
||||||
|
folderPath: 'Team',
|
||||||
|
isPinned: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(api.getDocuments).mockResolvedValue(docs);
|
||||||
|
vi.mocked(api.getFolders).mockResolvedValue(['Team']);
|
||||||
|
|
||||||
|
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// In pinned list
|
||||||
|
expect(screen.getByText('Pinned im Ordner')).toBeTruthy();
|
||||||
|
// Folder exists
|
||||||
|
expect(screen.getByText('Team')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user