feat(workspace): sidebar as a plain file explorer
This commit is contained in:
1 parent
6a5e82431f
commit
9f3c78dde0
2 files changed
+186
-30
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,14 +1330,19 @@ 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">
|
||||||
|
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
||||||
|
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
{/* Pinned Notes */}
|
{/* Pinned Notes */}
|
||||||
{pinnedDocs.length > 0 && (
|
{pinnedDocs.length > 0 && (
|
||||||
<div>
|
<div
|
||||||
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
className={`space-y-0.5 ${
|
||||||
<Pin className="w-3 h-3 text-status-warn" />
|
folderTree.length > 0 || rootDocs.length > 0
|
||||||
<span>Angeheftet</span>
|
? 'border-b border-border pb-1.5 mb-1.5'
|
||||||
</div>
|
: ''
|
||||||
<div className="space-y-0.5">
|
}`}
|
||||||
|
>
|
||||||
{pinnedDocs.map((d) => (
|
{pinnedDocs.map((d) => (
|
||||||
<div
|
<div
|
||||||
key={`pin-${d.id}`}
|
key={`pin-${d.id}`}
|
||||||
@@ -1354,20 +1359,19 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{d.icon || '📝'}</span>
|
<span className="shrink-0">{d.icon || '📝'}</span>
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Folders Tree with Recursive Hierarchical Nesting */}
|
{/* Folders and Root Documents */}
|
||||||
{folderTree.length > 0 && (
|
{(folderTree.length > 0 || rootDocs.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">
|
<div className="space-y-0.5">
|
||||||
{folderTree.map((node) => (
|
{folderTree.map((node) => (
|
||||||
<FolderTreeItem
|
<FolderTreeItem
|
||||||
@@ -1396,21 +1400,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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) => (
|
{rootDocs.map((d) => (
|
||||||
<div
|
<div
|
||||||
key={d.id}
|
key={d.id}
|
||||||
@@ -1427,7 +1417,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{d.icon || '📝'}</span>
|
<span className="shrink-0">{d.icon || '📝'}</span>
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
@@ -1437,6 +1427,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom drop target for moving to root */}
|
{/* Bottom drop target for moving to root */}
|
||||||
{(draggingDocId || draggingFolderPath) && (
|
{(draggingDocId || draggingFolderPath) && (
|
||||||
|
|||||||
@@ -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