From 8cefbe949a1addae6cb9fbc474e9f4f3503c4d9c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 21 Apr 2026 09:08:25 +0200 Subject: [PATCH] fix: refine derived inverse relationship labels --- src/components/InspectorPanels.test.tsx | 20 +++- .../inspector/ReferencedByPanel.tsx | 96 +++++++++++-------- .../relationshipDisplayLabels.test.tsx | 21 +++- src/utils/inverseRelationshipLabels.ts | 6 +- 4 files changed, 96 insertions(+), 47 deletions(-) diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 0721e6cd..48b717f0 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -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[0]) => rtlRender(ui, { wrapper: TooltipProvider }) + const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/vault/note/test.md', filename: 'test.md', @@ -548,11 +551,15 @@ describe('ReferencedByPanel', () => { ] render() + 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() + + fireEvent.focus(screen.getByText('Children')) + expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.') + }) + }) describe('GitHistoryPanel', () => { diff --git a/src/components/inspector/ReferencedByPanel.tsx b/src/components/inspector/ReferencedByPanel.tsx index 1b34724e..8c95b942 100644 --- a/src/components/inspector/ReferencedByPanel.tsx +++ b/src/components/inspector/ReferencedByPanel.tsx @@ -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>() + const map = new Map; inverseKeys: Set }>() for (const item of items) { const label = resolveInverseRelationshipLabel(item.viaKey, item.entry) - const entriesByPath = map.get(label) ?? new Map() - entriesByPath.set(item.entry.path, item.entry) - map.set(label, entriesByPath) + const group = map.get(label) ?? { entriesByPath: new Map(), inverseKeys: new Set() } + 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 ( -
-
- - Derived relationships - - - Read-only groups sourced from other notes. - -
-
- {grouped.map(([label, groupEntries]) => ( -
- - {label} - -
- {groupEntries.map((e) => { - const te = typeEntryMap[e.isA ?? ''] - return ( - onNavigate(e.title)} - title={e.archived ? 'Archived' : undefined} - TypeIcon={getTypeIcon(e.isA, te?.icon)} - /> - ) - })} +
+ +
+ {grouped.map(({ label, entries: groupEntries, inverseKeys }) => { + const tooltip = `Derived inverse relationship, inverse of ${inverseKeys.join(' and ')}.` + + return ( +
+ + + {label} + + +
+ {groupEntries.map((e) => { + const te = typeEntryMap[e.isA ?? ''] + return ( + onNavigate(e.title)} + title={e.archived ? 'Archived' : undefined} + TypeIcon={getTypeIcon(e.isA, te?.icon)} + /> + ) + })} +
-
- ))} + ) + })}
) diff --git a/src/components/inspector/relationshipDisplayLabels.test.tsx b/src/components/inspector/relationshipDisplayLabels.test.tsx index 0e08fc94..cbe83e6e 100644 --- a/src/components/inspector/relationshipDisplayLabels.test.tsx +++ b/src/components/inspector/relationshipDisplayLabels.test.tsx @@ -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[0]) => rtlRender(ui, { wrapper: TooltipProvider }) + const makeEntry = (overrides: Partial = {}): 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() 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() + + expect(screen.getByText('Children')).toBeInTheDocument() + expect(screen.queryByText('Events')).not.toBeInTheDocument() + expect(screen.queryByText('Referenced by')).not.toBeInTheDocument() + }) }) diff --git a/src/utils/inverseRelationshipLabels.ts b/src/utils/inverseRelationshipLabels.ts index 9898a0cd..9a3fbdd4 100644 --- a/src/utils/inverseRelationshipLabels.ts +++ b/src/utils/inverseRelationshipLabels.ts @@ -25,9 +25,11 @@ export function resolveInverseRelationshipLabel( } export function orderInverseRelationshipLabels(labels: Iterable): 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] }