fix: show all direct relationship properties in note list sidebar (#182)
The GroupBuilder's `seen` set was causing direct relationship properties to be suppressed. Reverse/computed groups (Children, Events) ran before the entity's own relationship keys, consuming entries into `seen` and preventing direct properties like "Belongs to" and "Notes" from appearing. Fix: process all direct relationship keys from entity.relationships before the reverse groups, so direct properties always take priority. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate } from './noteListHelpers'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups } from './noteListHelpers'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
@@ -154,3 +154,174 @@ describe('relativeDate', () => {
|
||||
expect(relativeDate(1700000000)).toMatch(/Nov 14/)
|
||||
})
|
||||
})
|
||||
|
||||
// --- buildRelationshipGroups tests ---
|
||||
|
||||
function makeVault(overrides: Partial<VaultEntry>[]): VaultEntry[] {
|
||||
return overrides.map((o, i) => makeEntry({
|
||||
path: `/Laputa/note/entry-${i}.md`,
|
||||
filename: `entry-${i}.md`,
|
||||
title: `Entry ${i}`,
|
||||
modifiedAt: 1700000000 - i * 100,
|
||||
...o,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('buildRelationshipGroups', () => {
|
||||
it('shows direct relationship properties from entity.relationships', () => {
|
||||
const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, building], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
|
||||
})
|
||||
|
||||
it('shows all direct relationships even when entries also appear as Children', () => {
|
||||
// The entity has "Notes" pointing at note1 and note2.
|
||||
// Those notes also have belongsTo pointing back at the entity.
|
||||
// Previously, Children consumed them via the seen set, suppressing "Notes".
|
||||
const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, note1, note2], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Notes')
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows all 5+ direct relationship properties', () => {
|
||||
const entries = makeVault([
|
||||
{ path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' },
|
||||
{ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' },
|
||||
{ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' },
|
||||
{ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' },
|
||||
{ path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' },
|
||||
{ path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' },
|
||||
])
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project',
|
||||
relationships: {
|
||||
'Belongs to': ['[[area/eng]]'],
|
||||
Notes: ['[[note/n1]]', '[[note/n2]]'],
|
||||
Owner: ['[[person/alice]]'],
|
||||
'Related to': ['[[project/sibling]]'],
|
||||
Topics: ['[[topic/rust]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, ...entries], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(labels).toContain('Notes')
|
||||
expect(labels).toContain('Owner')
|
||||
expect(labels).toContain('Related to')
|
||||
expect(labels).toContain('Topics')
|
||||
})
|
||||
|
||||
it('shows Children group for reverse belongsTo entries not covered by direct rels', () => {
|
||||
const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Children')
|
||||
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
|
||||
})
|
||||
|
||||
it('excludes Type key from relationship groups', () => {
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Type: ['[[type/project]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).not.toContain('Type')
|
||||
})
|
||||
|
||||
it('returns empty groups for entity with no relationships', () => {
|
||||
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
|
||||
const groups = buildRelationshipGroups(entity, [entity], {})
|
||||
expect(groups).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows single-item and multi-item relationship properties', () => {
|
||||
const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' })
|
||||
const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' })
|
||||
const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
|
||||
relationships: {
|
||||
Owner: ['[[person/alice]]'],
|
||||
Notes: ['[[note/n1]]', '[[note/n2]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2], {})
|
||||
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows Instances group for Type entities', () => {
|
||||
const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 })
|
||||
const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 })
|
||||
const typeEntity = makeEntry({
|
||||
path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project',
|
||||
isA: 'Type', relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Instances')
|
||||
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('direct relationships are sorted alphabetically', () => {
|
||||
const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' })
|
||||
const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' })
|
||||
const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
|
||||
relationships: {
|
||||
Zebra: ['[[note/c]]'],
|
||||
Alpha: ['[[note/a]]'],
|
||||
Middle: ['[[note/b]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, a, b, c], {})
|
||||
const directLabels = groups.map((g) => g.label)
|
||||
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
|
||||
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
|
||||
})
|
||||
|
||||
it('Referenced By shows entries whose relatedTo matches the entity', () => {
|
||||
const referer = makeEntry({
|
||||
path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer',
|
||||
relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, referer], {})
|
||||
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
|
||||
})
|
||||
|
||||
it('Backlinks shows entries that mention the entity via wikilinks in content', () => {
|
||||
const linker = makeEntry({
|
||||
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const allContent = { '/Laputa/note/linker.md': 'See [[Alpha]] for details.' }
|
||||
const groups = buildRelationshipGroups(entity, [entity, linker], allContent)
|
||||
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -220,17 +220,16 @@ export function buildRelationshipGroups(
|
||||
b.filterAndAdd('Instances', (e) => e.isA === entity.title)
|
||||
}
|
||||
|
||||
b.addFromRefs('Has', rels['Has'] ?? [])
|
||||
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
|
||||
b.addFromRefs('Topics', rels['Topics'] ?? [])
|
||||
|
||||
const handledKeys = new Set(['Has', 'Topics'])
|
||||
// Direct relationships first — all keys from entity.relationships take
|
||||
// priority so that reverse/computed groups (Children, Events, Referenced By)
|
||||
// only show *additional* entries not already covered by a direct property.
|
||||
Object.keys(rels)
|
||||
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
|
||||
.filter((k) => k.toLowerCase() !== 'type')
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
|
||||
|
||||
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
|
||||
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
||||
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user