fix: hide empty Backlinks, Relations, and Referenced By sections (#99)

* fix: hide empty backlinks, referenced-by, and relations sections

When a note has no backlinks, referenced-by entries, or relations,
the corresponding inspector sections are now completely hidden instead
of showing "No backlinks" / "No references" / "No relationships" labels.
This keeps the inspector clean and focused on actual content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: add before/after mockup for hide-backlinks-empty

Shows inspector panel comparison: cluttered empty state labels (before)
vs clean hidden sections (after).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: add before/after wireframes for hide-backlinks-empty

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 20:14:54 +01:00
committed by GitHub
parent ba2c1e5b4b
commit c177dd2e99
5 changed files with 134 additions and 92 deletions

View File

@@ -0,0 +1,65 @@
{
"children": [
{
"type": "frame",
"id": "hideBacklinksEmpty_before",
"x": 0,
"y": 0,
"name": "Hide Empty Backlinks Label — Before (shows No backlinks label)",
"width": 280,
"height": 80,
"fill": "#ffffff",
"gap": 4,
"padding": 12,
"alignItems": "flex-start",
"children": [
{
"type": "text",
"id": "bl_header_before",
"name": "section-header",
"fill": "#6b7280",
"content": "Backlinks",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600",
"letterSpacing": 1.2
},
{
"type": "text",
"id": "bl_empty_before",
"name": "empty-label",
"fill": "#9ca3af",
"content": "No backlinks",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "hideBacklinksEmpty_after",
"x": 320,
"y": 0,
"name": "Hide Empty Backlinks Label — After (section hidden when empty)",
"width": 280,
"height": 80,
"fill": "#ffffff",
"gap": 4,
"padding": 12,
"alignItems": "flex-start",
"children": [
{
"type": "text",
"id": "bl_note_after",
"name": "note",
"fill": "#9ca3af",
"content": "(Backlinks, Referenced By, and Relations sections are hidden when empty — no placeholder label shown)",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
}
]
}
]
}

View File

@@ -159,7 +159,7 @@ describe('Inspector', () => {
expect(onNavigate).toHaveBeenCalledWith('responsibility/grow-newsletter')
})
it('shows "No relationships" when entry has no belongsTo/relatedTo', () => {
it('hides relationships label when entry has no belongsTo/relatedTo', () => {
const noRels = { ...mockEntry, belongsTo: [], relatedTo: [] }
const contentNoRels = `---
title: Test Project
@@ -172,7 +172,7 @@ Status: Active
This is a test note with some words to count.
`
render(<Inspector {...defaultProps} entry={noRels} content={contentNoRels} />)
expect(screen.getByText('No relationships')).toBeInTheDocument()
expect(screen.queryByText('No relationships')).not.toBeInTheDocument()
})
it('shows backlinks from notes that reference the current note via outgoingLinks', () => {
@@ -197,8 +197,8 @@ This is a test note with some words to count.
entries={[mockEntry, { ...referrerEntry, outgoingLinks: [] }]}
/>
)
// Initially no backlinks because referrer has empty outgoingLinks
expect(screen.getByText('No backlinks')).toBeInTheDocument()
// Initially no backlinks — section is hidden entirely
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
rerender(
@@ -212,7 +212,7 @@ This is a test note with some words to count.
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
})
it('shows "No backlinks" when no notes reference the current note', () => {
it('hides backlinks section when no notes reference the current note', () => {
render(
<Inspector
{...defaultProps}
@@ -221,7 +221,8 @@ This is a test note with some words to count.
entries={[mockEntry]}
/>
)
expect(screen.getByText('No backlinks')).toBeInTheDocument()
expect(screen.queryByText('No backlinks')).not.toBeInTheDocument()
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
})
it('navigates when a backlink is clicked', () => {
@@ -499,7 +500,7 @@ Status: Active
expect(allTwos.some(el => el.classList.contains('ml-1'))).toBe(true)
})
it('shows "No references" when no entries reference the current note', () => {
it('hides referenced-by section when no entries reference the current note', () => {
render(
<Inspector
{...defaultProps}
@@ -509,7 +510,8 @@ Status: Active
/>
)
expect(screen.getByText('No references')).toBeInTheDocument()
expect(screen.queryByText('No references')).not.toBeInTheDocument()
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
@@ -549,8 +551,8 @@ Status: Active
)
// On Writing Well references responsibility via "Belongs to" (path match), not via "Type"
// But the Type entry is at type/responsibility.md, so wikilinks to
// responsibility/grow-newsletter won't match. Should show "No references"
expect(screen.getByText('No references')).toBeInTheDocument()
// responsibility/grow-newsletter won't match. Section should be hidden
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
it('resolves references via aliased wikilinks', () => {
@@ -612,8 +614,8 @@ Status: Active
// noteA shows in Referenced By (via Belongs to)
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches)
expect(screen.getByText('No backlinks')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
})
it('does not show self-references', () => {
@@ -633,7 +635,7 @@ Status: Active
/>
)
expect(screen.getByText('No references')).toBeInTheDocument()
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
})
})

View File

@@ -99,22 +99,7 @@ function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle
function EmptyInspector() {
return (
<>
<div><p className="m-0 text-[13px] text-muted-foreground">No note selected</p></div>
<div><p className="m-0 text-[13px] text-muted-foreground">No relationships</p></div>
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">Referenced by</h4>
<p className="m-0 text-[13px] text-muted-foreground">No references</p>
</div>
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">Backlinks</h4>
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
</div>
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
</div>
</>
<div><p className="m-0 text-[13px] text-muted-foreground">No note selected</p></div>
)
}

View File

@@ -81,7 +81,7 @@ describe('DynamicRelationshipsPanel', () => {
expect(chip!.style.color).toBe(expectedColor)
})
it('shows "No relationships" when frontmatter has no relations', () => {
it('hides empty state label when frontmatter has no relations', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
@@ -90,7 +90,7 @@ describe('DynamicRelationshipsPanel', () => {
onNavigate={onNavigate}
/>
)
expect(screen.getByText('No relationships')).toBeInTheDocument()
expect(screen.queryByText('No relationships')).not.toBeInTheDocument()
})
it('renders relationship groups with wikilinks', () => {
@@ -414,9 +414,9 @@ describe('BacklinksPanel', () => {
vi.clearAllMocks()
})
it('shows "No backlinks" when empty', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={[]} onNavigate={onNavigate} />)
expect(screen.getByText('No backlinks')).toBeInTheDocument()
it('renders nothing when empty', () => {
const { container } = render(<BacklinksPanel typeEntryMap={{}} backlinks={[]} onNavigate={onNavigate} />)
expect(container.innerHTML).toBe('')
})
it('renders backlink entries', () => {
@@ -450,9 +450,9 @@ describe('ReferencedByPanel', () => {
vi.clearAllMocks()
})
it('shows "No references" when items is empty', () => {
render(<ReferencedByPanel typeEntryMap={{}} items={[]} onNavigate={onNavigate} />)
expect(screen.getByText('No references')).toBeInTheDocument()
it('renders nothing when empty', () => {
const { container } = render(<ReferencedByPanel typeEntryMap={{}} items={[]} onNavigate={onNavigate} />)
expect(container.innerHTML).toBe('')
})
it('renders referenced-by entries grouped by relationship key', () => {

View File

@@ -364,16 +364,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
return (
<div>
{relationshipEntries.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
: relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
/>
))
}
{relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
/>
))}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
@@ -383,24 +380,20 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: { backlinks: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; onNavigate: (target: string) => void }) {
if (backlinks.length === 0) return null
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">
Backlinks {backlinks.length > 0 && <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>}
Backlinks <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>
</h4>
{backlinks.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
: (
<div className="flex flex-col gap-0.5">
{backlinks.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton key={e.path} label={e.title} typeColor={getTypeColor(e.isA, te?.color)} isArchived={e.archived} isTrashed={e.trashed} onClick={() => onNavigate(e.title)} title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} TypeIcon={getTypeIcon(e.isA, te?.icon)} />
)
})}
</div>
)
}
<div className="flex flex-col gap-0.5">
{backlinks.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton key={e.path} label={e.title} typeColor={getTypeColor(e.isA, te?.color)} isArchived={e.archived} isTrashed={e.trashed} onClick={() => onNavigate(e.title)} title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} TypeIcon={getTypeIcon(e.isA, te?.icon)} />
)
})}
</div>
</div>
)
}
@@ -425,42 +418,39 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
return Array.from(map.entries())
}, [items])
if (items.length === 0) return null
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">
Referenced by {items.length > 0 && <span className="ml-1" style={{ fontWeight: 400 }}>{items.length}</span>}
Referenced by <span className="ml-1" style={{ fontWeight: 400 }}>{items.length}</span>
</h4>
{items.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No references</p>
: (
<div className="flex flex-col gap-2.5">
{grouped.map(([viaKey, groupEntries]) => (
<div key={viaKey}>
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
via {viaKey}
</span>
<div className="flex flex-col gap-0.5">
{groupEntries.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
isTrashed={e.trashed}
onClick={() => onNavigate(e.title)}
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
</div>
))}
<div className="flex flex-col gap-2.5">
{grouped.map(([viaKey, groupEntries]) => (
<div key={viaKey}>
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
via {viaKey}
</span>
<div className="flex flex-col gap-0.5">
{groupEntries.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
isTrashed={e.trashed}
onClick={() => onNavigate(e.title)}
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
</div>
)
}
))}
</div>
</div>
)
}