feat: show icons on relationship chips

This commit is contained in:
Test
2026-04-07 22:09:21 +02:00
parent b54a5a5ad2
commit 11f2dec3e9
3 changed files with 233 additions and 18 deletions

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { NoteItem } from './NoteItem'
import type { VaultEntry } from '../types'
@@ -87,6 +87,148 @@ describe('NoteItem property chips', () => {
expect(screen.getByText('Steven Spielberg')).toBeInTheDocument()
})
it('shows the target note icon as a prefix on relationship chips', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: 'star',
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: 'users',
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
expect(chip?.querySelector('svg')).toBeInTheDocument()
expect(chip?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
})
it('falls back to the target type icon when the target note has no icon', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: null,
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: 'users',
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
expect(chip?.querySelector('svg')).toBeInTheDocument()
})
it('renders relationship chips without a prefix when neither note nor type has an icon', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: null,
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: null,
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
expect(chip?.querySelector('svg')).toBeNull()
expect(chip?.querySelector('img')).toBeNull()
})
it('falls back to the target type icon when the target note image icon fails to load', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: 'https://example.com/director.png',
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: 'users',
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
const image = chip?.querySelector('img')
expect(image).toBeInTheDocument()
fireEvent.error(image!)
expect(chip?.querySelector('img')).toBeNull()
expect(chip?.querySelector('svg')).toBeInTheDocument()
})
it('shows hostname for URL properties', () => {
const entry = makeEntry({
properties: { url: 'https://www.example.com/page/123' },

View File

@@ -1,4 +1,4 @@
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
import { createElement, useMemo, useState, type ComponentType, type SVGAttributes } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import { cn } from '@/lib/utils'
import {
@@ -7,9 +7,10 @@ import {
File, FileDashed,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
import { findIcon, resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { wikilinkDisplay } from '../utils/wikilink'
import { resolveNoteIcon } from '../utils/noteIcon'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../utils/wikilink'
import { NoteTitleIcon } from './NoteTitleIcon'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
@@ -69,30 +70,100 @@ function formatChipValue(value: unknown): string | null {
return s.length > 40 ? s.slice(0, 37) + '…' : s
}
function resolveChipValues(entry: VaultEntry, propName: string): string[] {
interface PropertyChipValue {
label: string
noteIcon?: string | null
typeIcon?: string | null
}
function resolveChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
// Check relationships first (wikilink values)
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase())
if (relKey) {
return entry.relationships[relKey].map((ref) => wikilinkDisplay(ref)).filter(Boolean)
return entry.relationships[relKey]
.map((ref) => {
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
const label = wikilinkDisplay(ref)
return label ? {
label,
noteIcon: targetEntry?.icon ?? null,
typeIcon: targetEntry?.isA ? typeEntryMap[targetEntry.isA]?.icon ?? null : null,
} : null
})
.filter((value): value is PropertyChipValue => value !== null)
}
// Check scalar properties
const propKey = Object.keys(entry.properties).find((k) => k.toLowerCase() === propName.toLowerCase())
if (!propKey) return []
const val = entry.properties[propKey]
if (Array.isArray(val)) return val.map((v) => formatChipValue(v)).filter((v): v is string => v !== null)
if (Array.isArray(val)) {
return val
.map((v) => formatChipValue(v))
.filter((v): v is string => v !== null)
.map((label) => ({ label }))
}
const formatted = formatChipValue(val)
return formatted ? [formatted] : []
return formatted ? [{ label: formatted }] : []
}
function PropertyChips({ entry, displayProps }: { entry: VaultEntry; displayProps: string[] }) {
function PropertyChipIcon({ noteIcon, typeIcon }: { noteIcon?: string | null; typeIcon?: string | null }) {
const [imageFailed, setImageFailed] = useState(false)
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
const TypeIcon = findIcon(typeIcon)
if (resolvedNoteIcon.kind === 'emoji') {
return (
<span aria-hidden="true" className="inline-flex shrink-0 items-center justify-center leading-none" style={{ fontSize: 11, lineHeight: 1 }}>
{resolvedNoteIcon.value}
</span>
)
}
if (resolvedNoteIcon.kind === 'phosphor') {
return <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
}
if (resolvedNoteIcon.kind === 'image' && !imageFailed) {
return (
<img
src={resolvedNoteIcon.src}
alt=""
aria-hidden="true"
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
onError={() => setImageFailed(true)}
/>
)
}
if (!TypeIcon) return null
return createElement(TypeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
}
function PropertyChips({
entry,
displayProps,
allEntries,
typeEntryMap,
}: {
entry: VaultEntry
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
}) {
const chips = useMemo(() => {
const result: { key: string; values: string[] }[] = []
const result: { key: string; values: PropertyChipValue[] }[] = []
for (const prop of displayProps) {
const values = resolveChipValues(entry, prop)
const values = resolveChipValues(entry, prop, allEntries, typeEntryMap)
if (values.length > 0) result.push({ key: prop, values })
}
return result
}, [entry, displayProps])
}, [entry, displayProps, allEntries, typeEntryMap])
if (chips.length === 0) return null
@@ -102,9 +173,10 @@ function PropertyChips({ entry, displayProps }: { entry: VaultEntry; displayProp
values.map((v, i) => (
<span
key={`${key}-${i}`}
className="inline-block max-w-full truncate rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
className="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{v}
<PropertyChipIcon noteIcon={v.noteIcon} typeIcon={v.typeIcon} />
<span className="truncate whitespace-nowrap">{v.label}</span>
</span>
))
)}
@@ -152,7 +224,7 @@ function resolveDisplayProps(entry: VaultEntry, typeEntryMap: Record<string, Vau
return typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: {
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: {
entry: VaultEntry
isSelected: boolean
isMultiSelected?: boolean
@@ -161,6 +233,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
/** When set, renders in Changes-view style: filename + change type icon */
changeStatus?: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
typeEntryMap: Record<string, VaultEntry>
allEntries?: VaultEntry[]
displayPropsOverride?: string[] | null
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
onPrefetch?: (path: string) => void
@@ -235,7 +308,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips entry={entry} displayProps={displayProps} />
<PropertyChips entry={entry} displayProps={displayProps} allEntries={allEntries ?? [entry]} typeEntryMap={typeEntryMap} />
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>

View File

@@ -339,8 +339,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
}, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu, inboxDisplayOverride])
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} allEntries={entries} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [entries, selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu, inboxDisplayOverride])
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)