fix: derive sidebar type sections dynamically from vault entries
Sidebar previously showed all 8 hardcoded BUILT_IN_SECTION_GROUPS regardless of whether any notes of those types existed in the vault. Now sections are derived from actual vault entries — only types with ≥1 active (non-trashed, non-archived) note appear. BUILT_IN_SECTION_GROUPS is retained as metadata lookup for icons/labels, not as the source of sections to display. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} 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(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} 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(<Sidebar entries={entriesNoBookInstance} selection={defaultSelection} onSelect={() => {}} />)
|
||||
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(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no sections when entries list is empty', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
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',
|
||||
|
||||
@@ -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<HTMLElement | null>, isOpen: boole
|
||||
}, [ref, isOpen, onClose])
|
||||
}
|
||||
|
||||
function applyOverrides(typeEntryMap: Record<string, VaultEntry>): 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<string> {
|
||||
const types = new Set<string>()
|
||||
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<string, VaultEntry>): 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<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
@@ -89,9 +103,8 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
|
||||
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => 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])
|
||||
|
||||
Reference in New Issue
Block a user