From 931fed879bc19ea708e8b7590f4ad807a3b3db5e Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 14:11:59 +0200 Subject: [PATCH] refactor: remove expand/collapse from sidebar type sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type sections are now flat rows — clicking selects the section and loads the note list. Removed chevrons, inline child lists, and "+" buttons. Added note count chip to each section header. FOLDERS section expand/ collapse is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Sidebar.test.tsx | 177 +++++--------------------------- src/components/Sidebar.tsx | 28 ++--- src/components/SidebarParts.tsx | 134 +++++------------------- 3 files changed, 61 insertions(+), 278 deletions(-) diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 6fcff34c..3ce9834d 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -252,50 +252,18 @@ describe('Sidebar', () => { expect(screen.queryByText('Types')).not.toBeInTheDocument() }) - it('shows entity names under their section groups after expanding', () => { + it('does not show inline entity names — sections are flat rows', () => { render( {}} />) - // Sections start collapsed by default — expand them first - fireEvent.click(screen.getByLabelText('Expand Projects')) - fireEvent.click(screen.getByLabelText('Expand Responsibilities')) - fireEvent.click(screen.getByLabelText('Expand Experiments')) - fireEvent.click(screen.getByLabelText('Expand Procedures')) - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - expect(screen.getByText('Grow Newsletter')).toBeInTheDocument() - expect(screen.getByText('Stock Screener')).toBeInTheDocument() - expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument() - }) - - it('shows People and Events items after expanding', () => { - render( {}} />) - fireEvent.click(screen.getByLabelText('Expand People')) - fireEvent.click(screen.getByLabelText('Expand Events')) - expect(screen.getByText('Alice')).toBeInTheDocument() - expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() - }) - - it('collapses and expands sections', () => { - render( {}} />) - // Start collapsed — items hidden - expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() - - // Expand - fireEvent.click(screen.getByLabelText('Expand Projects')) - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - - // Collapse - fireEvent.click(screen.getByLabelText('Collapse Projects')) + // Individual entries should NOT appear inline in the sidebar expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() + expect(screen.queryByText('Grow Newsletter')).not.toBeInTheDocument() }) - it('calls onSelect when clicking an entity', () => { - const onSelect = vi.fn() - render() - fireEvent.click(screen.getByLabelText('Expand Projects')) - fireEvent.click(screen.getByText('Build Laputa App')) - expect(onSelect).toHaveBeenCalledWith({ - kind: 'entity', - entry: mockEntries[0], - }) + it('shows note count chip on type sections', () => { + render( {}} />) + // Projects section has 1 entry — count chip should be a sibling of the label + const projectsHeader = screen.getByText('Projects').closest('[class*="group/section"]')! + expect(projectsHeader.textContent).toContain('1') }) it('calls onSelect when clicking a section header', () => { @@ -308,37 +276,12 @@ describe('Sidebar', () => { }) }) - it('expands a collapsed section when clicking its header', () => { - render( {}} />) - // Sections start collapsed — items hidden - expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() - // Click the section header text (not the chevron) - fireEvent.click(screen.getByText('Projects')) - // Section should now be expanded - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - }) - - it('collapses an expanded+selected section when clicking its header again', () => { - const projectSelection: SidebarSelection = { kind: 'sectionGroup', type: 'Project' } - render( {}} />) - // First click expands (starts collapsed) and selects - fireEvent.click(screen.getByText('Projects')) - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - // Second click: section is expanded + selected → should collapse - fireEvent.click(screen.getByText('Projects')) - expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() - }) - - it('selects but keeps expanded an unselected expanded section when clicking its header', () => { + it('selects on every click — no expand/collapse toggle', () => { const onSelect = vi.fn() render() - // Expand via chevron first - fireEvent.click(screen.getByLabelText('Expand Projects')) - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() - // Click the header — section is expanded but not selected → should select and stay expanded fireEvent.click(screen.getByText('Projects')) - expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Project' }) - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + fireEvent.click(screen.getByText('Projects')) + expect(onSelect).toHaveBeenCalledTimes(2) }) it('calls onSelect with sectionGroup for People', () => { @@ -351,41 +294,15 @@ describe('Sidebar', () => { }) }) - it('renders Topics section with topic entries after expanding', () => { + it('renders Topics section header', () => { render( {}} />) expect(screen.getByText('Topics')).toBeInTheDocument() - fireEvent.click(screen.getByLabelText('Expand Topics')) - expect(screen.getByText('Software Development')).toBeInTheDocument() - expect(screen.getByText('Trading')).toBeInTheDocument() + // Topic entries are NOT shown inline + expect(screen.queryByText('Software Development')).not.toBeInTheDocument() }) - it('calls onSelect with entity kind when clicking a topic', () => { - const onSelect = vi.fn() - render() - fireEvent.click(screen.getByLabelText('Expand Topics')) - fireEvent.click(screen.getByText('Software Development')) - expect(onSelect).toHaveBeenCalledWith({ - kind: 'entity', - entry: mockEntries[4], - }) - }) - - it('renders + buttons for each section group when onCreateType is provided', () => { - const onCreateType = vi.fn() - render( {}} onCreateType={onCreateType} />) - const createButtons = screen.getAllByTitle(/^New /) - expect(createButtons.length).toBe(7) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics (no Type entries → no Types section) - }) - - it('calls onCreateType with correct type when + button is clicked', () => { - const onCreateType = vi.fn() - render( {}} onCreateType={onCreateType} />) - fireEvent.click(screen.getByTitle('New Project')) - expect(onCreateType).toHaveBeenCalledWith('Project') - }) - - it('does not render + buttons when onCreateType is not provided', () => { - render( {}} />) + it('does not render + buttons on type sections', () => { + render( {}} onCreateType={() => {}} />) expect(screen.queryByTitle('New Project')).not.toBeInTheDocument() }) @@ -517,24 +434,9 @@ describe('Sidebar', () => { expect(screen.getByText('Recipes')).toBeInTheDocument() }) - it('shows instances of custom types under their section after expanding', () => { + it('does not show inline instances — sections are flat rows', () => { render( {}} onCreateType={() => {}} />) - fireEvent.click(screen.getByLabelText('Expand Recipes')) - expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument() - }) - - it('renders + button on custom type sections for creating instances', () => { - const onCreateType = vi.fn() - render( {}} onCreateType={onCreateType} />) - fireEvent.click(screen.getByTitle('New Recipe')) - expect(onCreateType).toHaveBeenCalledWith('Recipe') - }) - - it('calls onCreateNewType when + is clicked on Types section', () => { - const onCreateNewType = vi.fn() - render( {}} onCreateNewType={onCreateNewType} />) - fireEvent.click(screen.getByTitle('New Type')) - expect(onCreateNewType).toHaveBeenCalled() + expect(screen.queryByText('Pasta Carbonara')).not.toBeInTheDocument() }) it('does not show section for type with zero active entries', () => { @@ -926,11 +828,10 @@ describe('Sidebar', () => { expect(screen.getByText('Notes')).toBeInTheDocument() }) - it('includes both explicit and untyped notes under Notes section', () => { + it('counts both explicit and untyped notes in Notes section chip', () => { render( {}} />) - fireEvent.click(screen.getByLabelText('Expand Notes')) - expect(screen.getByText('Explicit Note')).toBeInTheDocument() - expect(screen.getByText('Untyped Note')).toBeInTheDocument() + const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')! + expect(notesHeader.textContent).toContain('2') }) it('shows Notes section for untyped entries even without explicit Note entries', () => { @@ -1007,16 +908,8 @@ describe('Sidebar', () => { expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' }) }) - describe('emoji icon in sidebar section children', () => { + it('does not show inline entries — no child items in type sections', () => { const entriesWithEmoji: VaultEntry[] = [ - { - path: '/vault/project.md', filename: 'project.md', title: 'Project', isA: 'Type', - aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, - archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, - fileSize: 200, snippet: '', wordCount: 0, relationships: {}, - icon: 'rocket-launch', color: 'purple', order: null, sidebarLabel: null, template: null, - sort: null, view: null, visible: null, outgoingLinks: [], properties: {}, - }, { path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build App', isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, @@ -1025,30 +918,8 @@ describe('Sidebar', () => { icon: '🚀', color: null, order: null, sidebarLabel: null, template: null, sort: null, view: null, visible: null, outgoingLinks: [], properties: {}, }, - { - path: '/vault/project/no-icon.md', filename: 'no-icon.md', title: 'No Icon Project', - isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, - cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, - createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {}, - icon: null, color: null, order: null, sidebarLabel: null, template: null, - sort: null, view: null, visible: null, outgoingLinks: [], properties: {}, - }, ] - - it('shows emoji icon before title in expanded section child', () => { - render( {}} />) - fireEvent.click(screen.getByLabelText('Expand Projects')) - const buildApp = screen.getByText('Build App') - const parent = buildApp.closest('div')! - expect(parent.textContent).toBe('🚀Build App') - }) - - it('does not show emoji for notes without icon', () => { - render( {}} />) - fireEvent.click(screen.getByLabelText('Expand Projects')) - const noIcon = screen.getByText('No Icon Project') - const parent = noIcon.closest('div')! - expect(parent.textContent).toBe('No Icon Project') - }) + render( {}} />) + expect(screen.queryByText('Build App')).not.toBeInTheDocument() }) }) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3ee5c693..d7b17a6e 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -112,24 +112,21 @@ function applyCustomization( function SortableSection({ group, sectionProps }: { group: SectionGroup - sectionProps: Omit - & { entries: VaultEntry[]; collapsed: Record; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string } + sectionProps: Omit + & { entries: VaultEntry[]; renamingType: string | null; renameInitialValue: string } }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type }) - const items = sectionProps.entries.filter((e) => + const itemCount = sectionProps.entries.filter((e) => !e.archived && !e.trashed && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type), - ) - const isCollapsed = sectionProps.collapsed[group.type] ?? true + ).length const isRenaming = sectionProps.renamingType === group.type return ( -
+
sectionProps.onToggle(group.type)} + onContextMenu={sectionProps.onContextMenu} dragHandleProps={listeners} isRenaming={isRenaming} renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined} @@ -205,12 +202,11 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang // --- Main Sidebar --- export const Sidebar = memo(function Sidebar({ - entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, + entries, selection, onSelect, onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection, onToggleTypeVisibility, folders = [], onCreateFolder, inboxCount = 0, onCollapse, }: SidebarProps) { - const [collapsed, setCollapsed] = useState>({}) const [customizeTarget, setCustomizeTarget] = useState(null) const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) const [renamingType, setRenamingType] = useState(null) @@ -236,10 +232,6 @@ export const Sidebar = memo(function Sidebar({ useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu) useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget) - const toggleSection = useCallback((type: string) => { - setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) })) - }, []) - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), @@ -280,8 +272,8 @@ export const Sidebar = memo(function Sidebar({ }, [customizeTarget, onUpdateTypeTemplate]) const sectionProps = { - entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, - onContextMenu: handleContextMenu, onToggle: toggleSection, + entries, selection, onSelect, + onContextMenu: handleContextMenu, renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename, } diff --git a/src/components/SidebarParts.tsx b/src/components/SidebarParts.tsx index 24b2162a..fb5e3513 100644 --- a/src/components/SidebarParts.tsx +++ b/src/components/SidebarParts.tsx @@ -1,9 +1,7 @@ import { type ComponentType, useState, useEffect, useRef } from 'react' -import type { VaultEntry, SidebarSelection } from '../types' +import type { SidebarSelection } from '../types' import { cn } from '@/lib/utils' -import { isEmoji } from '../utils/emoji' -import { ChevronRight, ChevronDown, Plus } from 'lucide-react' -import { getTypeColor, getTypeLightColor } from '../utils/typeColors' +import { getTypeColor } from '../utils/typeColors' import { type IconProps } from '@phosphor-icons/react' export interface SectionGroup { @@ -77,15 +75,10 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName = export interface SectionContentProps { group: SectionGroup - items: VaultEntry[] - isCollapsed: boolean + itemCount: number selection: SidebarSelection onSelect: (sel: SidebarSelection) => void - onSelectNote?: (entry: VaultEntry) => void - onCreateType?: (type: string) => void - onCreateNewType?: () => void onContextMenu: (e: React.MouseEvent, type: string) => void - onToggle: () => void dragHandleProps?: Record isRenaming?: boolean renameInitialValue?: string @@ -93,75 +86,28 @@ export interface SectionContentProps { onRenameCancel?: () => void } -function childSelection(entry: VaultEntry): SidebarSelection { - return { kind: 'entity', entry } -} - -function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined { - const isType = type === 'Type' - if (!onCreateType && !(isType && onCreateNewType)) return undefined - return isType ? () => onCreateNewType?.() : () => onCreateType?.(type) -} - export function SectionContent({ - group, items, isCollapsed, selection, onSelect, onSelectNote, - onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps, + group, itemCount, selection, onSelect, + onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel, }: SectionContentProps) { const { label, type, Icon, customColor } = group const sectionColor = getTypeColor(type, customColor) - const sectionLightColor = getTypeLightColor(type, customColor) - const onCreate = resolveCreateHandler(type, onCreateType, onCreateNewType) return ( - <> - onSelect({ kind: 'sectionGroup', type })} - onContextMenu={(e) => onContextMenu(e, type)} - onToggle={onToggle} - onCreate={(e) => { e.stopPropagation(); onCreate?.() }} - dragHandleProps={dragHandleProps} - isRenaming={isRenaming} - renameInitialValue={renameInitialValue} - onRenameSubmit={onRenameSubmit} - onRenameCancel={onRenameCancel} - /> - {!isCollapsed && items.length > 0 && ( - - )} - - ) -} - -function SectionChildList({ items, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: { - items: VaultEntry[]; selection: SidebarSelection - sectionColor: string; sectionLightColor: string - onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void -}) { - return ( -
- {items.map((entry) => { - const sel = childSelection(entry) - const active = isSelectionActive(selection, sel) - return ( - { onSelect(sel); onSelectNote?.(entry) }} - /> - ) - })} -
+ onSelect({ kind: 'sectionGroup', type })} + onContextMenu={(e) => onContextMenu(e, type)} + dragHandleProps={dragHandleProps} + isRenaming={isRenaming} + renameInitialValue={renameInitialValue} + onRenameSubmit={onRenameSubmit} + onRenameCancel={onRenameCancel} + /> ) } @@ -195,11 +141,10 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: { ) } -function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: { +function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: { label: string; type: string; Icon: ComponentType - sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean + sectionColor: string; itemCount: number; isActive: boolean onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void - onToggle: () => void; onCreate: (e: React.MouseEvent) => void dragHandleProps?: Record isRenaming?: boolean; renameInitialValue?: string onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void @@ -209,12 +154,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")} style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }} {...dragHandleProps} - onClick={() => { - if (isRenaming) return - if (isCollapsed) { onToggle(); onSelect() } - else if (isActive) { onToggle() } - else { onSelect() } - }} onContextMenu={isRenaming ? undefined : onContextMenu} + onClick={() => { if (!isRenaming) onSelect() }} + onContextMenu={isRenaming ? undefined : onContextMenu} >
@@ -229,32 +170,11 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, {label} )}
-
- {showCreate && ( - - )} - -
-
- ) -} - -function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: { - title: string; icon?: string | null; isActive: boolean - sectionColor?: string; sectionLightColor?: string - onClick: () => void -}) { - return ( -
- {icon && isEmoji(icon) && {icon}}{title} + {itemCount > 0 && ( + + {itemCount} + + )}
) }