diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx index a2f5b7c2..3130d445 100644 --- a/src/components/NoteItem.test.tsx +++ b/src/components/NoteItem.test.tsx @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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' }, diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 88bc4a72..4875b2e7 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -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>> = { @@ -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, +): 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 ( + + ) + } + + if (resolvedNoteIcon.kind === 'phosphor') { + return