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:
65
design/hide-backlinks-empty.pen
Normal file
65
design/hide-backlinks-empty.pen
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user