From 9f3c78dde0070ea7162f489444c5eb24c0808314 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 22:08:35 +0100 Subject: [PATCH] feat(workspace): sidebar as a plain file explorer --- .../src/features/workspace/WorkspaceView.tsx | 201 +++++++++--------- .../__tests__/WorkspaceView.sidebar.test.tsx | 165 ++++++++++++++ 2 files changed, 261 insertions(+), 105 deletions(-) create mode 100644 frontend/src/features/workspace/__tests__/WorkspaceView.sidebar.test.tsx diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 19d3f9b..7df2e50 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -911,7 +911,7 @@ export const WorkspaceView: React.FC = ({ ); const rootDocs = useMemo( - () => docs.filter((d) => !d.folderPath), + () => docs.filter((d) => !d.folderPath && !d.isPinned), [docs] ); @@ -1330,114 +1330,105 @@ export const WorkspaceView: React.FC = ({ {/* Scrollable Navigation Tree */}
- {/* Pinned Notes */} - {pinnedDocs.length > 0 && ( + {pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? ( +
Noch keine Notizen
+ ) : (
-
- - Angeheftet -
-
- {pinnedDocs.map((d) => ( -
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' - }`} - > - {d.icon || '📝'} - {d.title || 'Unbenannte Notiz'} -
- ))} -
+ {/* Pinned Notes */} + {pinnedDocs.length > 0 && ( +
0 || rootDocs.length > 0 + ? 'border-b border-border pb-1.5 mb-1.5' + : '' + }`} + > + {pinnedDocs.map((d) => ( +
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' + }`} + > + {d.icon || '📝'} + {d.title || 'Unbenannte Notiz'} + {d.sharedWith && d.sharedWith.length > 0 && ( + + )} + +
+ ))} +
+ )} + + {/* Folders and Root Documents */} + {(folderTree.length > 0 || rootDocs.length > 0) && ( +
+ {folderTree.map((node) => ( + { + setSelectedDocId(id); + setTargetBlockId(null); + setNewlyCreatedDocId(null); + setSubView('editor'); + setMobileShowDetail(true); + }} + /> + ))} + + {rootDocs.map((d) => ( +
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' + }`} + > + {d.icon || '📝'} + {d.title || 'Unbenannte Notiz'} + {d.sharedWith && d.sharedWith.length > 0 && ( + + )} +
+ ))} +
+ )}
)} - {/* Folders Tree with Recursive Hierarchical Nesting */} - {folderTree.length > 0 && ( -
-
- Ordner -
-
- {folderTree.map((node) => ( - { - setSelectedDocId(id); - setTargetBlockId(null); - setNewlyCreatedDocId(null); - setSubView('editor'); - setMobileShowDetail(true); - }} - /> - ))} -
-
- )} - - {/* Root Documents */} -
-
- Notizen (Hauptebene) -
- {rootDocs.length === 0 ? ( -
- Keine Notizen auf der Hauptebene. -
- ) : ( -
- {rootDocs.map((d) => ( -
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' - }`} - > - {d.icon || '📝'} - {d.title || 'Unbenannte Notiz'} - {d.sharedWith && d.sharedWith.length > 0 && ( - - )} -
- ))} -
- )} -
- {/* Bottom drop target for moving to root */} {(draggingDocId || draggingFolderPath) && (
({ + DocumentEditor: () =>
DocumentEditor
, +})); + +vi.mock('../../../lib/api', async (importOriginal) => { + const actual = await importOriginal(); + 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 & { 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + // In pinned list + expect(screen.getByText('Pinned im Ordner')).toBeTruthy(); + // Folder exists + expect(screen.getByText('Team')).toBeTruthy(); + }); + }); +});