fix: refine derived inverse relationship labels

This commit is contained in:
lucaronin
2026-04-21 09:08:25 +02:00
parent dd0a55b8e8
commit 8cefbe949a
4 changed files with 96 additions and 47 deletions

View File

@@ -1,13 +1,16 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { TooltipProvider } from './ui/tooltip'
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
@@ -548,11 +551,15 @@ describe('ReferencedByPanel', () => {
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
expect(screen.getByTestId('derived-relationships-separator')).toBeInTheDocument()
expect(screen.getByText('Write Essays')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('Referenced by')).toBeInTheDocument()
expect(screen.queryByText('Derived relationships')).not.toBeInTheDocument()
expect(screen.queryByText('Read-only groups sourced from other notes.')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
@@ -572,6 +579,17 @@ describe('ReferencedByPanel', () => {
expect(screen.getByTitle('Archived')).toBeInTheDocument()
})
it('adds an inverse-relationship tooltip to each derived relationship label', async () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'Child Note' }), viaKey: 'Belongs to' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
fireEvent.focus(screen.getByText('Children'))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
})
})
describe('GitHistoryPanel', () => {

View File

@@ -1,8 +1,12 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../../types'
import { orderInverseRelationshipLabels, resolveInverseRelationshipLabel } from '../../utils/inverseRelationshipLabels'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { getTypeColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { PROPERTY_PANEL_LABEL_CLASS_NAME } from '../propertyPanelLayout'
import { Separator } from '../ui/separator'
import { ActionTooltip } from '../ui/action-tooltip'
import { LinkButton } from './LinkButton'
export interface ReferencedByItem {
@@ -16,57 +20,65 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
onNavigate: (target: string) => void
}) {
const grouped = useMemo(() => {
const map = new Map<string, Map<string, VaultEntry>>()
const map = new Map<string, { entriesByPath: Map<string, VaultEntry>; inverseKeys: Set<string> }>()
for (const item of items) {
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
const entriesByPath = map.get(label) ?? new Map<string, VaultEntry>()
entriesByPath.set(item.entry.path, item.entry)
map.set(label, entriesByPath)
const group = map.get(label) ?? { entriesByPath: new Map<string, VaultEntry>(), inverseKeys: new Set<string>() }
group.entriesByPath.set(item.entry.path, item.entry)
group.inverseKeys.add(humanizePropertyKey(item.viaKey))
map.set(label, group)
}
return orderInverseRelationshipLabels(map.keys()).map((label) => [
label,
[...(map.get(label)?.values() ?? [])],
] as const)
return orderInverseRelationshipLabels(map.keys())
.map((label) => {
const group = map.get(label)
if (!group) return null
return {
entries: [...group.entriesByPath.values()],
inverseKeys: [...group.inverseKeys].sort((left, right) => left.localeCompare(right)),
label,
}
})
.filter((group): group is { entries: VaultEntry[]; inverseKeys: string[]; label: string } => group !== null && group.entries.length > 0)
}, [items])
if (items.length === 0) return null
if (grouped.length === 0) return null
return (
<div className="referenced-by-panel">
<div className="mb-2 flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
Derived relationships
</span>
<span className="text-[10px] text-muted-foreground/80">
Read-only groups sourced from other notes.
</span>
</div>
<div className="flex flex-col gap-2.5">
{grouped.map(([label, groupEntries]) => (
<div key={label}>
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
{label}
</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}
noteIcon={e.icon}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
onClick={() => onNavigate(e.title)}
title={e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
<div className="referenced-by-panel flex flex-col gap-3">
<Separator data-testid="derived-relationships-separator" />
<div className="flex flex-col gap-3">
{grouped.map(({ label, entries: groupEntries, inverseKeys }) => {
const tooltip = `Derived inverse relationship, inverse of ${inverseKeys.join(' and ')}.`
return (
<div key={label} className="flex min-w-0 flex-col gap-1 px-1.5">
<ActionTooltip copy={{ label: tooltip }} side="top" align="start">
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} tabIndex={0} data-testid="derived-relationship-label">
{label}
</span>
</ActionTooltip>
<div className="min-w-0 flex flex-col gap-0.5">
{groupEntries.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
noteIcon={e.icon}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
onClick={() => onNavigate(e.title)}
title={e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
</div>
</div>
))}
)
})}
</div>
</div>
)

View File

@@ -1,9 +1,12 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { fireEvent, render as rtlRender, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DynamicRelationshipsPanel } from './RelationshipsPanel'
import { ReferencedByPanel, type ReferencedByItem } from './ReferencedByPanel'
import { TooltipProvider } from '../ui/tooltip'
import type { VaultEntry } from '../../types'
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: TooltipProvider })
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
@@ -105,7 +108,7 @@ describe('relationship display labels', () => {
expect(relationshipRow).toHaveStyle({ gridColumn: '1 / -1' })
})
it('humanizes snake_case keys in the referenced-by panel', () => {
it('humanizes snake_case keys in the referenced-by panel', async () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
]
@@ -113,6 +116,8 @@ describe('relationship display labels', () => {
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
expect(screen.getByText('Children')).toBeInTheDocument()
fireEvent.focus(screen.getByText('Children'))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.')
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
})
@@ -133,4 +138,16 @@ describe('relationship display labels', () => {
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
})
it('does not show empty preferred inverse groups', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' }), viaKey: 'belongs_to' },
]
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
})

View File

@@ -25,9 +25,11 @@ export function resolveInverseRelationshipLabel(
}
export function orderInverseRelationshipLabels(labels: Iterable<string>): string[] {
const customLabels = [...labels]
const presentLabels = [...labels]
const preferredLabels = PREFERRED_INVERSE_RELATIONSHIP_LABELS.filter((label) => presentLabels.includes(label))
const customLabels = presentLabels
.filter((label) => !PREFERRED_INVERSE_RELATIONSHIP_LABELS.includes(label as typeof PREFERRED_INVERSE_RELATIONSHIP_LABELS[number]))
.sort((left, right) => left.localeCompare(right))
return [...PREFERRED_INVERSE_RELATIONSHIP_LABELS, ...customLabels]
return [...preferredLabels, ...customLabels]
}