feat: show icons on relationship chips
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user