diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 3b3bdfe0..81ea9fbd 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -227,7 +227,7 @@ describe('Sidebar', () => { expect(screen.getByText('Favorites')).toBeInTheDocument() }) - it('renders section group headers with new labels', () => { + it('renders section group headers only for types present in entries', () => { render( {}} />) expect(screen.getByText('Projects')).toBeInTheDocument() expect(screen.getByText('Experiments')).toBeInTheDocument() @@ -236,7 +236,8 @@ describe('Sidebar', () => { expect(screen.getByText('People')).toBeInTheDocument() expect(screen.getByText('Events')).toBeInTheDocument() expect(screen.getByText('Topics')).toBeInTheDocument() - expect(screen.getByText('Types')).toBeInTheDocument() + // No entries with isA: 'Type' in mockEntries → Types section absent + expect(screen.queryByText('Types')).not.toBeInTheDocument() }) it('shows entity names under their section groups after expanding', () => { @@ -328,7 +329,7 @@ describe('Sidebar', () => { const onCreateType = vi.fn() render( {}} onCreateType={onCreateType} />) const createButtons = screen.getAllByTitle(/^New /) - expect(createButtons.length).toBe(8) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics, Types + 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', () => { @@ -445,14 +446,39 @@ describe('Sidebar', () => { snippet: '', wordCount: 0, relationships: {}, - icon: null, - color: null, - order: null, - outgoingLinks: [], + icon: null, + color: null, + order: null, + outgoingLinks: [], + }, + { + path: '/vault/book/ddia.md', + filename: 'ddia.md', + title: 'Designing Data-Intensive Applications', + isA: 'Book', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 400, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + outgoingLinks: [], }, ] - it('renders custom type sections derived from Type entries', () => { + it('renders custom type sections derived from actual entries', () => { render( {}} onCreateType={() => {}} />) expect(screen.getByText('Books')).toBeInTheDocument() expect(screen.getByText('Recipes')).toBeInTheDocument() @@ -478,6 +504,36 @@ describe('Sidebar', () => { expect(onCreateNewType).toHaveBeenCalled() }) + it('does not show section for type with zero active entries', () => { + // Only Type definitions exist for Book, no actual Book instances + const entriesNoBookInstance = entriesWithCustomTypes.filter((e) => !(e.isA === 'Book' && e.title !== 'Book')) + render( {}} />) + expect(screen.queryByText('Books')).not.toBeInTheDocument() + // Recipes still has an instance (Pasta Carbonara) + expect(screen.getByText('Recipes')).toBeInTheDocument() + }) + + it('hides type section when all entries of that type are trashed', () => { + const entriesWithTrashedOnly: VaultEntry[] = [ + { + path: '/vault/event/cancelled.md', filename: 'cancelled.md', title: 'Cancelled Event', + isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, + cadence: null, archived: false, trashed: true, trashedAt: 1700000000, + modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0, + relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], + }, + ] + render( {}} />) + expect(screen.queryByText('Events')).not.toBeInTheDocument() + }) + + it('shows no sections when entries list is empty', () => { + render( {}} />) + expect(screen.queryByText('Projects')).not.toBeInTheDocument() + expect(screen.queryByText('People')).not.toBeInTheDocument() + expect(screen.queryByText('Events')).not.toBeInTheDocument() + }) + it('does not show built-in types as custom sections', () => { const projectTypeEntry: VaultEntry = { path: '/vault/type/project.md', diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ebdbf8e9..5227dccf 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react' import type { VaultEntry, SidebarSelection } from '../types' import { resolveIcon } from '../utils/iconRegistry' import { buildTypeEntryMap } from '../utils/typeColors' +import { pluralizeType } from '../hooks/useCommandRegistry' import { TypeCustomizePopover } from './TypeCustomizePopover' import { useSectionVisibility } from '../hooks/useSectionVisibility' import { @@ -48,7 +49,8 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [ { label: 'Types', type: 'Type', Icon: StackSimple }, ] -const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type)) +/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */ +const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg])) // --- Hooks --- @@ -63,19 +65,31 @@ function useOutsideClick(ref: React.RefObject, isOpen: boole }, [ref, isOpen, onClose]) } -function applyOverrides(typeEntryMap: Record): SectionGroup[] { - return BUILT_IN_SECTION_GROUPS.map((sg) => { - const te = typeEntryMap[sg.type] - if (!te?.icon && !te?.color) return sg - return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null } - }) +/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */ +function collectActiveTypes(entries: VaultEntry[]): Set { + const types = new Set() + for (const e of entries) { + if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA) + } + return types } -function buildCustomSections(entries: VaultEntry[]): SectionGroup[] { - return entries - .filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title)) - .sort((a, b) => a.title.localeCompare(b.title)) - .map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color })) +/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */ +function buildSectionGroup(type: string, typeEntryMap: Record): SectionGroup { + const builtIn = BUILT_IN_TYPE_MAP.get(type) + const typeEntry = typeEntryMap[type] + const customColor = typeEntry?.color ?? null + if (builtIn) { + const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon + return { ...builtIn, Icon, customColor } + } + return { label: pluralizeType(type), type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor } +} + +/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */ +function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record): SectionGroup[] { + const activeTypes = collectActiveTypes(entries) + return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap)) } function sortSections(groups: SectionGroup[], typeEntryMap: Record): SectionGroup[] { @@ -89,9 +103,8 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record boolean) { const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const allSectionGroups = useMemo(() => { - const built = applyOverrides(typeEntryMap) - const custom = buildCustomSections(entries) - return sortSections([...built, ...custom], typeEntryMap) + const sections = buildDynamicSections(entries, typeEntryMap) + return sortSections(sections, typeEntryMap) }, [entries, typeEntryMap]) const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible]) const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])