fix: refine derived inverse relationship labels
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user